from __future__ import annotations import json import time import uuid import urllib.error import urllib.request from pathlib import Path from typing import Any from .config import settings def _request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: int = 10) -> dict[str, Any]: data = None headers = {"Accept": "application/json"} if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=timeout) as response: body = response.read().decode("utf-8", errors="replace") return {"passed": 200 <= response.status < 300, "status": response.status, "body": body, "json": json.loads(body) if body else None} except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") return {"passed": False, "status": exc.code, "body": body} except Exception as exc: return {"passed": False, "error": str(exc)} def _request_text(url: str, timeout: int = 10) -> dict[str, Any]: try: with urllib.request.urlopen(url, timeout=timeout) as response: body = response.read().decode("utf-8", errors="replace") return {"passed": 200 <= response.status < 300, "status": response.status, "body": body} except Exception as exc: return {"passed": False, "error": str(exc)} def _post_multipart(url: str, field: str, filename: str, content: bytes, content_type: str = "text/plain", timeout: int = 10) -> dict[str, Any]: boundary = f"----SegAcceptance{uuid.uuid4().hex}" body = b"".join( [ f"--{boundary}\r\n".encode(), f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'.encode(), f"Content-Type: {content_type}\r\n\r\n".encode(), content, f"\r\n--{boundary}--\r\n".encode(), ] ) request = urllib.request.Request( url, data=body, method="POST", headers={"Content-Type": f"multipart/form-data; boundary={boundary}", "Accept": "application/json"}, ) try: with urllib.request.urlopen(request, timeout=timeout) as response: text = response.read().decode("utf-8", errors="replace") return {"passed": 200 <= response.status < 300, "status": response.status, "body": text, "json": json.loads(text) if text else None} except urllib.error.HTTPError as exc: text = exc.read().decode("utf-8", errors="replace") return {"passed": False, "status": exc.code, "body": text} except Exception as exc: return {"passed": False, "error": str(exc)} def _poll_job(base_url: str, job_id: str, timeout: int = 90) -> dict[str, Any]: deadline = time.time() + timeout last = None while time.time() < deadline: result = _request_json("GET", f"{base_url}/api/jobs/{job_id}", timeout=10) last = result job = result.get("json") if result.get("passed") else None if job and job.get("status") in {"success", "failed", "cancelled"}: return {"passed": job.get("status") == "success", "job": job} time.sleep(1) return {"passed": False, "error": "job timed out", "last": last} def _create_job_and_wait(base_url: str, task_type: str, params: dict[str, Any], timeout: int = 90) -> dict[str, Any]: created = _request_json("POST", f"{base_url}/api/jobs", {"type": task_type, "params": params}, timeout=10) if not created.get("passed") or not created.get("json"): return {"passed": False, "created": created} job_id = created["json"]["id"] polled = _poll_job(base_url, job_id, timeout=timeout) events = _request_text(f"{base_url}/api/jobs/{job_id}/events", timeout=10) return {"passed": polled["passed"], "created": created["json"], "polled": polled, "events": events} def _create_job_with_retry(base_url: str, task_type: str, params: dict[str, Any], attempts: int = 2, timeout: int = 90) -> dict[str, Any]: results = [] for _ in range(attempts): result = _create_job_and_wait(base_url, task_type, params, timeout=timeout) results.append(result) if result.get("passed"): return {"passed": True, "attempts": results} time.sleep(1) return {"passed": False, "attempts": results} def _write_acceptance_images(root: Path) -> tuple[Path, Path, Path]: import cv2 import numpy as np image_dir = root / "images" label_dir = root / "labels" result_dir = root / "stacked" image_dir.mkdir(parents=True, exist_ok=True) label_dir.mkdir(parents=True, exist_ok=True) result_dir.mkdir(parents=True, exist_ok=True) image = np.zeros((16, 16, 3), dtype=np.uint8) image[:, :, 1] = 180 label = np.zeros((16, 16, 3), dtype=np.uint8) label[4:12, 4:12, 2] = 255 image_path = image_dir / "sample.png" label_path = label_dir / "sample.png" cv2.imwrite(str(image_path), image) cv2.imwrite(str(label_path), label) return image_path, label_path, result_dir def latest_acceptance_report() -> dict[str, Any]: path = settings.project_root / "var" / "acceptance" / "latest.json" if not path.exists(): return {"available": False, "path": str(path)} return json.loads(path.read_text(encoding="utf-8")) def run_live_acceptance(base_url: str = "http://127.0.0.1:8010") -> dict[str, Any]: """Run a lightweight end-to-end smoke against the live API and job runner.""" acceptance_root = settings.project_root / "var" / "acceptance" run_id = uuid.uuid4().hex[:8] fixture_root = acceptance_root / f"run_{run_id}" fixture_root.mkdir(parents=True, exist_ok=True) checks: list[dict[str, Any]] = [] dataset_name = f"acceptance_{run_id}" created_dataset = _request_json("POST", f"{base_url}/api/datasets", {"name": dataset_name, "description": "acceptance smoke"}, timeout=10) checks.append({"name": "create_dataset_api", "passed": created_dataset.get("passed", False), "detail": created_dataset}) upload = _post_multipart( f"{base_url}/api/datasets/{dataset_name}/upload/labels", "files", "label 01.txt", b"0 0.5 0.5 0.25 0.25\n", "text/plain", timeout=10, ) checks.append({"name": "upload_label_api", "passed": upload.get("passed", False), "detail": upload}) artifact_ok = False artifact_detail: dict[str, Any] = {"skipped": True} try: relative_path = upload["json"]["saved"][0]["relative_path"] artifact_detail = _request_text(f"{base_url}/api/artifacts/{relative_path}", timeout=10) artifact_ok = artifact_detail.get("passed", False) and "0 0.5" in artifact_detail.get("body", "") except Exception as exc: artifact_detail = {"error": str(exc)} checks.append({"name": "artifact_api_for_uploaded_label", "passed": artifact_ok, "detail": artifact_detail}) mock = _create_job_and_wait(base_url, "mock.echo", {"message": f"acceptance {run_id}"}, timeout=45) mock_log = mock.get("polled", {}).get("job", {}).get("log_tail", "") checks.append({"name": "mock_job_runner", "passed": mock.get("passed", False) and f"acceptance {run_id}" in mock_log, "detail": mock}) image_path, label_path, result_dir = _write_acceptance_images(fixture_root) stack = _create_job_with_retry( base_url, "dataset.stack_single", { "image_path": str(image_path), "label_path": str(label_path), "result_dir": str(result_dir), "alpha": 0.35, }, attempts=2, timeout=90, ) output_path = result_dir / "sample.png" checks.append( { "name": "legacy_stack_job_runner", "passed": stack.get("passed", False) and output_path.exists() and output_path.stat().st_size > 0, "detail": {**stack, "output_path": str(output_path), "output_exists": output_path.exists()}, } ) report = { "available": True, "run_id": run_id, "base_url": base_url, "passed": all(item["passed"] for item in checks), "checks": checks, "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } latest = acceptance_root / "latest.json" latest.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report