contracts.py python
223 lines 7.6 KB
Raw
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a feat(slice-7): T3 dataset review lifecycle, job queue, prov… Human minor ⚠ breaking 42 days ago
1 """Training API request contracts and schema-layer validation."""
2
3 from __future__ import annotations
4
5 import hashlib
6 import json
7 import re
8 from dataclasses import dataclass, field
9 from enum import Enum
10 from types import MappingProxyType
11 from typing import Mapping
12
13 from scooling_lab.errors import ApiError, ErrorCode
14 from scooling_lab.retention import (
15 RetentionPolicy,
16 default_retention_policy,
17 retention_policy_from_mapping,
18 )
19
20
21 class TrainingJobStatus(str, Enum):
22 """States allowed by the T2 training job state machine."""
23
24 QUEUED = "queued"
25 RUNNING = "running"
26 SUCCEEDED = "succeeded"
27 FAILED = "failed"
28 CANCELLED = "cancelled"
29 DELETED = "deleted"
30
31
32 ALLOWED_MODEL_IDS: frozenset[str] = frozenset({"fixture-tiny-llm"})
33 ALLOWED_DATASET_IDS: frozenset[str] = frozenset({"fixture:synthetic-tiny-v1"})
34 ALLOWED_REQUEST_KEYS: frozenset[str] = frozenset(
35 {
36 "idempotencyKey",
37 "datasetId",
38 "modelId",
39 "requestedBy",
40 "retentionPolicy",
41 "trainingParameters",
42 }
43 )
44 FORBIDDEN_KEY_TERMS: tuple[str, ...] = (
45 "url",
46 "uri",
47 "path",
48 "file",
49 "shell",
50 "command",
51 "callback",
52 "webhook",
53 "worker",
54 )
55 SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9._:-]{3,96}$")
56 REQUESTER_RE = re.compile(r"^[A-Za-z0-9._:-]{3,80}$")
57 JOB_ID_RE = re.compile(r"^job_[a-f0-9]{24}$")
58 ARTIFACT_ID_RE = re.compile(r"^artifact_[a-f0-9]{24}$")
59 FORBIDDEN_STRING_RE = re.compile(
60 r"(?i)(https?://|file://|ssh://|[;&|`$<>]|\.\./|/\w|[A-Za-z]:\\)"
61 )
62
63
64 @dataclass(frozen=True)
65 class TrainingJobRequest:
66 """Validated createTrainingJob payload.
67
68 The schema only accepts inert fixture identifiers, bounded numeric training
69 parameters, and a caller-provided idempotency key. It rejects unknown keys so
70 browser-supplied worker URLs, callback URLs, file paths, and shell strings
71 fail before reaching the service layer.
72 """
73
74 idempotency_key: str
75 dataset_id: str
76 model_id: str
77 requested_by: str
78 retention_policy: RetentionPolicy = field(default_factory=default_retention_policy)
79 training_parameters: Mapping[str, int | float | bool] = field(
80 default_factory=lambda: MappingProxyType({})
81 )
82
83 @classmethod
84 def from_mapping(cls, payload: Mapping[str, object]) -> "TrainingJobRequest":
85 """Validate and convert an untrusted JSON object into a request."""
86
87 reject_forbidden_keys(payload)
88 unknown_keys = set(payload).difference(ALLOWED_REQUEST_KEYS)
89 if unknown_keys:
90 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
91
92 idempotency_key = require_safe_identifier(payload.get("idempotencyKey"))
93 dataset_id = require_safe_identifier(payload.get("datasetId"))
94 model_id = require_safe_identifier(payload.get("modelId"))
95 requested_by = require_requester(payload.get("requestedBy"))
96 retention_policy = retention_policy_from_mapping(payload.get("retentionPolicy"))
97 training_parameters = validate_training_parameters(
98 payload.get("trainingParameters", {})
99 )
100
101 if model_id not in ALLOWED_MODEL_IDS:
102 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
103
104 return cls(
105 idempotency_key=idempotency_key,
106 dataset_id=dataset_id,
107 model_id=model_id,
108 requested_by=requested_by,
109 retention_policy=retention_policy,
110 training_parameters=MappingProxyType(training_parameters),
111 )
112
113 def fingerprint(self) -> str:
114 """Return a stable hash input for idempotent job creation."""
115
116 return json.dumps(
117 {
118 "datasetId": self.dataset_id,
119 "idempotencyKey": self.idempotency_key,
120 "modelId": self.model_id,
121 "requestedBy": self.requested_by,
122 "retentionPolicy": self.retention_policy.to_public_dict(),
123 "trainingParameters": dict(sorted(self.training_parameters.items())),
124 },
125 separators=(",", ":"),
126 sort_keys=True,
127 )
128
129 def stable_job_id(self) -> str:
130 """Return the deterministic job id for this request."""
131
132 digest = hashlib.sha256(self.fingerprint().encode("utf-8")).hexdigest()[:24]
133 return f"job_{digest}"
134
135 def to_public_dict(self) -> dict[str, object]:
136 """Serialize only safe, non-private request fields."""
137
138 return {
139 "datasetId": self.dataset_id,
140 "modelId": self.model_id,
141 "requestedBy": self.requested_by,
142 "retentionPolicy": self.retention_policy.to_public_dict(),
143 "trainingParameters": dict(sorted(self.training_parameters.items())),
144 }
145
146
147 def reject_forbidden_keys(payload: Mapping[str, object]) -> None:
148 """Reject explicitly dangerous key shapes anywhere in a request payload."""
149
150 for key, value in payload.items():
151 lowered = key.lower()
152 if any(term in lowered for term in FORBIDDEN_KEY_TERMS):
153 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
154 if isinstance(value, Mapping):
155 reject_forbidden_keys(value)
156
157
158 def require_safe_identifier(value: object) -> str:
159 """Validate compact fixture identifiers and reject path or URL strings."""
160
161 if not isinstance(value, str) or not SAFE_IDENTIFIER_RE.fullmatch(value):
162 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
163 if FORBIDDEN_STRING_RE.search(value):
164 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
165 return value
166
167
168 def require_requester(value: object) -> str:
169 """Validate the non-secret caller label used for fixture audit context."""
170
171 if not isinstance(value, str) or not REQUESTER_RE.fullmatch(value):
172 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
173 if FORBIDDEN_STRING_RE.search(value):
174 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
175 return value
176
177
178 def require_job_id(value: object) -> str:
179 """Validate a server-generated job id before any store lookup."""
180
181 if not isinstance(value, str) or not JOB_ID_RE.fullmatch(value):
182 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
183 return value
184
185
186 def require_artifact_id(value: object) -> str:
187 """Validate a server-generated artifact id before any mutation."""
188
189 if not isinstance(value, str) or not ARTIFACT_ID_RE.fullmatch(value):
190 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
191 return value
192
193
194 def validate_training_parameters(value: object) -> dict[str, int | float | bool]:
195 """Validate the bounded dry-run parameter set for the fake worker."""
196
197 if not isinstance(value, Mapping):
198 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
199 allowed_keys = {"epochs", "learningRate", "dryRun"}
200 if set(value).difference(allowed_keys):
201 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
202
203 parameters: dict[str, int | float | bool] = {}
204 if "epochs" in value:
205 epochs = value["epochs"]
206 if not isinstance(epochs, int) or isinstance(epochs, bool) or not 1 <= epochs <= 3:
207 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
208 parameters["epochs"] = epochs
209 if "learningRate" in value:
210 learning_rate = value["learningRate"]
211 if (
212 not isinstance(learning_rate, (int, float))
213 or isinstance(learning_rate, bool)
214 or not 0 < float(learning_rate) <= 1
215 ):
216 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
217 parameters["learningRate"] = float(learning_rate)
218 if "dryRun" in value:
219 dry_run = value["dryRun"]
220 if not isinstance(dry_run, bool) or dry_run is not True:
221 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
222 parameters["dryRun"] = dry_run
223 return parameters
File History 1 commit
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a feat(slice-7): T3 dataset review lifecycle, job queue, prov… Human minor 42 days ago