dataset_review.py
python
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a
feat(slice-7): T3 dataset review lifecycle, job queue, prov…
Human
minor
⚠ breaking
41 days ago
| 1 | """Dataset registration and review lifecycle for the Scooling Lab training API. |
| 2 | |
| 3 | Datasets move through a bounded state machine before any job can reference them: |
| 4 | registered → pending_review → approved | rejected |
| 5 | |
| 6 | Only approved datasets are eligible for job submission. Rejection is communicated |
| 7 | as a machine-readable ``RejectionReasonCode`` enum; no caller-supplied text is ever |
| 8 | echoed back in API responses or logs. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import re |
| 14 | import threading |
| 15 | from dataclasses import dataclass, field |
| 16 | from enum import Enum |
| 17 | from types import MappingProxyType |
| 18 | from typing import Mapping |
| 19 | |
| 20 | from scooling_lab.errors import ApiError, ErrorCode |
| 21 | |
| 22 | |
| 23 | DATASET_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{3,96}$") |
| 24 | FORBIDDEN_STRING_RE = re.compile( |
| 25 | r"(?i)(https?://|file://|ssh://|[;&|`$<>]|\.\./|/\w|[A-Za-z]:\\)" |
| 26 | ) |
| 27 | |
| 28 | # The fixture dataset is pre-approved so existing tests require no changes. |
| 29 | FIXTURE_APPROVED_DATASET_IDS: frozenset[str] = frozenset( |
| 30 | {"fixture:synthetic-tiny-v1"} |
| 31 | ) |
| 32 | |
| 33 | |
| 34 | class DatasetStatus(str, Enum): |
| 35 | """States in the dataset review lifecycle.""" |
| 36 | |
| 37 | REGISTERED = "registered" |
| 38 | PENDING_REVIEW = "pending_review" |
| 39 | APPROVED = "approved" |
| 40 | REJECTED = "rejected" |
| 41 | |
| 42 | |
| 43 | class RejectionReasonCode(str, Enum): |
| 44 | """Bounded machine-readable reason codes for dataset rejection. |
| 45 | |
| 46 | Free text is never accepted or echoed. Only one of these values may |
| 47 | appear in a review decision. |
| 48 | """ |
| 49 | |
| 50 | SYNTHETIC_LIMIT = "SYNTHETIC_LIMIT" |
| 51 | FORMAT_INVALID = "FORMAT_INVALID" |
| 52 | POLICY_VIOLATION = "POLICY_VIOLATION" |
| 53 | SCHEMA_MISMATCH = "SCHEMA_MISMATCH" |
| 54 | DUPLICATE_SUBMISSION = "DUPLICATE_SUBMISSION" |
| 55 | |
| 56 | |
| 57 | _DATASET_TRANSITIONS: MappingProxyType[ |
| 58 | DatasetStatus, frozenset[DatasetStatus] |
| 59 | ] = MappingProxyType( |
| 60 | { |
| 61 | DatasetStatus.REGISTERED: frozenset({DatasetStatus.PENDING_REVIEW}), |
| 62 | DatasetStatus.PENDING_REVIEW: frozenset( |
| 63 | {DatasetStatus.APPROVED, DatasetStatus.REJECTED} |
| 64 | ), |
| 65 | DatasetStatus.APPROVED: frozenset(), |
| 66 | DatasetStatus.REJECTED: frozenset(), |
| 67 | } |
| 68 | ) |
| 69 | |
| 70 | |
| 71 | def dataset_transition( |
| 72 | current: DatasetStatus, target: DatasetStatus |
| 73 | ) -> DatasetStatus: |
| 74 | """Apply the dataset review state machine or raise for invalid moves. |
| 75 | |
| 76 | Idempotent same-state replay is allowed for every state. |
| 77 | """ |
| 78 | |
| 79 | if current == target: |
| 80 | return current |
| 81 | if target in _DATASET_TRANSITIONS[current]: |
| 82 | return target |
| 83 | raise ApiError(ErrorCode.INVALID_TRANSITION, 409) |
| 84 | |
| 85 | |
| 86 | def require_dataset_id(value: object) -> str: |
| 87 | """Validate a dataset id against the safe-identifier contract.""" |
| 88 | |
| 89 | if not isinstance(value, str) or not DATASET_ID_RE.fullmatch(value): |
| 90 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 91 | if FORBIDDEN_STRING_RE.search(value): |
| 92 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 93 | return value |
| 94 | |
| 95 | |
| 96 | def require_rejection_reason(value: object) -> RejectionReasonCode: |
| 97 | """Validate a rejection reason code; reject unknown or free-text values.""" |
| 98 | |
| 99 | if not isinstance(value, str): |
| 100 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 101 | try: |
| 102 | return RejectionReasonCode(value) |
| 103 | except ValueError as exc: |
| 104 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) from exc |
| 105 | |
| 106 | |
| 107 | def _utc_now_iso() -> str: |
| 108 | """Return a UTC timestamp formatted for public metadata.""" |
| 109 | |
| 110 | from datetime import UTC, datetime |
| 111 | |
| 112 | return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
| 113 | |
| 114 | |
| 115 | @dataclass |
| 116 | class DatasetRecord: |
| 117 | """Internal record for one registered fixture dataset.""" |
| 118 | |
| 119 | dataset_id: str |
| 120 | status: DatasetStatus = DatasetStatus.REGISTERED |
| 121 | registered_at: str = field(default_factory=_utc_now_iso) |
| 122 | updated_at: str = field(default_factory=_utc_now_iso) |
| 123 | rejection_reason: RejectionReasonCode | None = None |
| 124 | |
| 125 | def to_public_dict(self) -> dict[str, object]: |
| 126 | """Serialize the safe dataset status shape for API responses.""" |
| 127 | |
| 128 | payload: dict[str, object] = { |
| 129 | "datasetId": self.dataset_id, |
| 130 | "status": self.status.value, |
| 131 | "registeredAt": self.registered_at, |
| 132 | "updatedAt": self.updated_at, |
| 133 | } |
| 134 | if self.rejection_reason is not None: |
| 135 | payload["rejectionReasonCode"] = self.rejection_reason.value |
| 136 | return payload |
| 137 | |
| 138 | |
| 139 | class DatasetStore: |
| 140 | """Thread-safe, in-memory store for dataset review lifecycle records. |
| 141 | |
| 142 | The store is pre-populated with the fixture dataset IDs from |
| 143 | ``FIXTURE_APPROVED_DATASET_IDS`` so that existing job submission flows |
| 144 | continue to work without an explicit registration step. |
| 145 | """ |
| 146 | |
| 147 | def __init__(self) -> None: |
| 148 | """Create a store pre-approving all fixture dataset ids.""" |
| 149 | |
| 150 | self._lock = threading.RLock() |
| 151 | self._datasets: dict[str, DatasetRecord] = {} |
| 152 | ts = _utc_now_iso() |
| 153 | for dataset_id in FIXTURE_APPROVED_DATASET_IDS: |
| 154 | self._datasets[dataset_id] = DatasetRecord( |
| 155 | dataset_id=dataset_id, |
| 156 | status=DatasetStatus.APPROVED, |
| 157 | registered_at=ts, |
| 158 | updated_at=ts, |
| 159 | ) |
| 160 | |
| 161 | def register(self, dataset_id: str) -> DatasetRecord: |
| 162 | """Register a dataset; idempotent if already registered. |
| 163 | |
| 164 | Raises ``CONFLICT`` (409) if the same id has already been approved or |
| 165 | rejected so that callers cannot silently re-enter a terminal-path |
| 166 | dataset into review. |
| 167 | """ |
| 168 | |
| 169 | require_dataset_id(dataset_id) |
| 170 | with self._lock: |
| 171 | existing = self._datasets.get(dataset_id) |
| 172 | if existing is not None: |
| 173 | if existing.status in {DatasetStatus.APPROVED, DatasetStatus.REJECTED}: |
| 174 | raise ApiError(ErrorCode.CONFLICT, 409) |
| 175 | return existing |
| 176 | record = DatasetRecord(dataset_id=dataset_id) |
| 177 | self._datasets[dataset_id] = record |
| 178 | return record |
| 179 | |
| 180 | def submit_for_review(self, dataset_id: str) -> DatasetRecord: |
| 181 | """Advance a registered dataset to pending_review.""" |
| 182 | |
| 183 | require_dataset_id(dataset_id) |
| 184 | with self._lock: |
| 185 | record = self._get_locked(dataset_id) |
| 186 | record.status = dataset_transition( |
| 187 | record.status, DatasetStatus.PENDING_REVIEW |
| 188 | ) |
| 189 | record.updated_at = _utc_now_iso() |
| 190 | return record |
| 191 | |
| 192 | def approve(self, dataset_id: str) -> DatasetRecord: |
| 193 | """Approve a dataset that is pending review.""" |
| 194 | |
| 195 | require_dataset_id(dataset_id) |
| 196 | with self._lock: |
| 197 | record = self._get_locked(dataset_id) |
| 198 | record.status = dataset_transition(record.status, DatasetStatus.APPROVED) |
| 199 | record.updated_at = _utc_now_iso() |
| 200 | return record |
| 201 | |
| 202 | def reject( |
| 203 | self, dataset_id: str, reason_code: RejectionReasonCode |
| 204 | ) -> DatasetRecord: |
| 205 | """Reject a dataset that is pending review with a bounded reason code.""" |
| 206 | |
| 207 | require_dataset_id(dataset_id) |
| 208 | with self._lock: |
| 209 | record = self._get_locked(dataset_id) |
| 210 | record.status = dataset_transition(record.status, DatasetStatus.REJECTED) |
| 211 | record.rejection_reason = reason_code |
| 212 | record.updated_at = _utc_now_iso() |
| 213 | return record |
| 214 | |
| 215 | def get(self, dataset_id: str) -> DatasetRecord: |
| 216 | """Return one dataset record or raise a safe not-found error.""" |
| 217 | |
| 218 | require_dataset_id(dataset_id) |
| 219 | with self._lock: |
| 220 | return self._get_locked(dataset_id) |
| 221 | |
| 222 | def is_approved(self, dataset_id: str) -> bool: |
| 223 | """Return True only if the dataset is in the approved state.""" |
| 224 | |
| 225 | try: |
| 226 | require_dataset_id(dataset_id) |
| 227 | except ApiError: |
| 228 | return False |
| 229 | with self._lock: |
| 230 | record = self._datasets.get(dataset_id) |
| 231 | return record is not None and record.status == DatasetStatus.APPROVED |
| 232 | |
| 233 | def _get_locked(self, dataset_id: str) -> DatasetRecord: |
| 234 | record = self._datasets.get(dataset_id) |
| 235 | if record is None: |
| 236 | raise ApiError(ErrorCode.NOT_FOUND, 404) |
| 237 | return record |
| 238 | |
| 239 | |
| 240 | def validate_review_request(payload: Mapping[str, object]) -> tuple[str, RejectionReasonCode | None]: |
| 241 | """Parse and validate a dataset review action payload. |
| 242 | |
| 243 | Returns ``(action, reason_code)`` where ``reason_code`` is non-None only |
| 244 | when ``action == "reject"``. No caller-supplied text is echoed. |
| 245 | """ |
| 246 | |
| 247 | allowed_keys = {"action", "reasonCode"} |
| 248 | if not isinstance(payload, Mapping): |
| 249 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 250 | unknown = set(payload) - allowed_keys |
| 251 | if unknown: |
| 252 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 253 | |
| 254 | action = payload.get("action") |
| 255 | if action not in {"approve", "reject"}: |
| 256 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 257 | |
| 258 | reason_code: RejectionReasonCode | None = None |
| 259 | if action == "reject": |
| 260 | raw_reason = payload.get("reasonCode") |
| 261 | if raw_reason is None: |
| 262 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 263 | reason_code = require_rejection_reason(raw_reason) |
| 264 | else: |
| 265 | if "reasonCode" in payload: |
| 266 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 267 | |
| 268 | return str(action), reason_code |
File History
1 commit
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a
feat(slice-7): T3 dataset review lifecycle, job queue, prov…
Human
minor
⚠
41 days ago