414 lines
14 KiB
Python
414 lines
14 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import base64
|
||
|
|
from copy import deepcopy
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from cmvr_edge_ai.client.detect import (
|
||
|
|
DetectClient,
|
||
|
|
DetectClientClosedError,
|
||
|
|
DetectClientConfigurationError,
|
||
|
|
DetectHttpStatusError,
|
||
|
|
DetectProtocolError,
|
||
|
|
DetectTransportError,
|
||
|
|
)
|
||
|
|
from cmvr_edge_ai.application import create_default_registry, validate_application
|
||
|
|
from cmvr_edge_ai.config import load_config
|
||
|
|
from cmvr_edge_ai.contracts.catalog import ModelCatalog, ServingMode
|
||
|
|
from cmvr_edge_ai.contracts.inference import (
|
||
|
|
DetectionsOutput,
|
||
|
|
InferenceResponse,
|
||
|
|
InferenceStatus,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||
|
|
|
||
|
|
|
||
|
|
class FakeResponse:
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
status_code: int,
|
||
|
|
payload: Any = None,
|
||
|
|
*,
|
||
|
|
json_error: BaseException | None = None,
|
||
|
|
text: str = "",
|
||
|
|
) -> None:
|
||
|
|
self.status_code = status_code
|
||
|
|
self._payload = payload
|
||
|
|
self._json_error = json_error
|
||
|
|
self.text = text
|
||
|
|
|
||
|
|
def json(self) -> Any:
|
||
|
|
if self._json_error is not None:
|
||
|
|
raise self._json_error
|
||
|
|
return deepcopy(self._payload)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeHttpClient:
|
||
|
|
def __init__(self, *outcomes: Any) -> None:
|
||
|
|
self._outcomes = list(outcomes)
|
||
|
|
self.calls: list[dict[str, Any]] = []
|
||
|
|
self.close_count = 0
|
||
|
|
|
||
|
|
async def request(self, method: str, path: str, **kwargs: Any) -> FakeResponse:
|
||
|
|
self.calls.append({"method": method, "path": path, **deepcopy(kwargs)})
|
||
|
|
if not self._outcomes:
|
||
|
|
raise AssertionError("fake HTTP client has no configured outcome")
|
||
|
|
outcome = self._outcomes.pop(0)
|
||
|
|
if isinstance(outcome, BaseException):
|
||
|
|
raise outcome
|
||
|
|
if callable(outcome):
|
||
|
|
return outcome(self.calls[-1])
|
||
|
|
return outcome
|
||
|
|
|
||
|
|
async def aclose(self) -> None:
|
||
|
|
self.close_count += 1
|
||
|
|
|
||
|
|
|
||
|
|
def _catalog_payload() -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"schema_version": "cmvr.model-catalog/v1",
|
||
|
|
"active_push": [],
|
||
|
|
"passive_invoke": [
|
||
|
|
{
|
||
|
|
"category": "detect.ppe",
|
||
|
|
"family": "detection",
|
||
|
|
"model_id": "construction-ppe-yolov8@1",
|
||
|
|
"serving_modes": ["passive_invoke"],
|
||
|
|
"registration_status": "registered",
|
||
|
|
"deployment_status": "ready",
|
||
|
|
"input_kinds": ["image"],
|
||
|
|
"output_kinds": ["detections"],
|
||
|
|
"artifact_roles": ["annotated"],
|
||
|
|
"parameters_schema": {},
|
||
|
|
"name": "Construction PPE",
|
||
|
|
"backend": "ultralytics",
|
||
|
|
"description": "PPE violations",
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _inference_response(call: dict[str, Any]) -> FakeResponse:
|
||
|
|
request = call["json"]
|
||
|
|
return FakeResponse(
|
||
|
|
200,
|
||
|
|
{
|
||
|
|
"schema_version": "cmvr.inference-response/v1",
|
||
|
|
"request_id": request["request_id"],
|
||
|
|
"trace_id": "trace-1",
|
||
|
|
"category": request["category"],
|
||
|
|
"model": {
|
||
|
|
"model_id": "construction-ppe-yolov8@1",
|
||
|
|
"backend": "ultralytics",
|
||
|
|
"name": "Construction PPE",
|
||
|
|
},
|
||
|
|
"status": "succeeded",
|
||
|
|
"outputs": [
|
||
|
|
{
|
||
|
|
"kind": "detections",
|
||
|
|
"name": "detections",
|
||
|
|
"coordinate_space": "pixel_xyxy",
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"label": "No-Helmet",
|
||
|
|
"confidence": 0.94,
|
||
|
|
"box": {
|
||
|
|
"x_min": 10.0,
|
||
|
|
"y_min": 20.0,
|
||
|
|
"x_max": 100.0,
|
||
|
|
"y_max": 200.0,
|
||
|
|
},
|
||
|
|
"attributes": {},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"artifacts": [],
|
||
|
|
"timing": {"queue_ms": 1.0, "inference_ms": 12.5, "total_ms": 14.0},
|
||
|
|
"warnings": [],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_list_models_returns_strict_catalog_without_a_request_body() -> None:
|
||
|
|
async def scenario() -> tuple[ModelCatalog, FakeHttpClient]:
|
||
|
|
http = FakeHttpClient(FakeResponse(200, _catalog_payload()))
|
||
|
|
client = DetectClient("http://detect.invalid", http_client=http)
|
||
|
|
return await client.list_models(), http
|
||
|
|
|
||
|
|
catalog, http = asyncio.run(scenario())
|
||
|
|
|
||
|
|
assert isinstance(catalog, ModelCatalog)
|
||
|
|
assert catalog.active_push == ()
|
||
|
|
assert catalog.passive_invoke[0].category == "detect.ppe"
|
||
|
|
assert catalog.passive_invoke[0].serving_modes == (ServingMode.PASSIVE_INVOKE,)
|
||
|
|
assert http.calls == [{"method": "GET", "path": "/v1/models"}]
|
||
|
|
|
||
|
|
|
||
|
|
def test_infer_sends_versioned_base64_json_and_returns_typed_response() -> None:
|
||
|
|
image = b"\xff\xd8test-jpeg\xff\xd9"
|
||
|
|
|
||
|
|
async def scenario() -> tuple[InferenceResponse, FakeHttpClient]:
|
||
|
|
http = FakeHttpClient(_inference_response)
|
||
|
|
client = DetectClient("http://detect.invalid", http_client=http)
|
||
|
|
response = await client.infer(
|
||
|
|
"detect.ppe",
|
||
|
|
image,
|
||
|
|
media_type="IMAGE/JPEG",
|
||
|
|
parameters={"confidence": 0.5, "max_detections": 20},
|
||
|
|
image_roles=("annotated",),
|
||
|
|
)
|
||
|
|
return response, http
|
||
|
|
|
||
|
|
response, http = asyncio.run(scenario())
|
||
|
|
|
||
|
|
assert isinstance(response, InferenceResponse)
|
||
|
|
assert response.status is InferenceStatus.SUCCEEDED
|
||
|
|
assert isinstance(response.outputs[0], DetectionsOutput)
|
||
|
|
assert response.outputs[0].items[0].label == "No-Helmet"
|
||
|
|
assert len(http.calls) == 1
|
||
|
|
call = http.calls[0]
|
||
|
|
assert call["method"] == "POST"
|
||
|
|
assert call["path"] == "/v1/inference"
|
||
|
|
body = call["json"]
|
||
|
|
assert body["schema_version"] == "cmvr.inference-request/v1"
|
||
|
|
assert body["category"] == "detect.ppe"
|
||
|
|
assert body["parameters"] == {"confidence": 0.5, "max_detections": 20}
|
||
|
|
assert body["requested_artifact_roles"] == ["annotated"]
|
||
|
|
assert body["inputs"] == [
|
||
|
|
{
|
||
|
|
"kind": "image",
|
||
|
|
"name": "image",
|
||
|
|
"media_type": "image/jpeg",
|
||
|
|
"encoding": "base64",
|
||
|
|
"data": base64.b64encode(image).decode("ascii"),
|
||
|
|
"width": None,
|
||
|
|
"height": None,
|
||
|
|
"sha256": None,
|
||
|
|
}
|
||
|
|
]
|
||
|
|
assert "model_id" not in body
|
||
|
|
assert "weights" not in body
|
||
|
|
assert "pipeline" not in body
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("kwargs", "message"),
|
||
|
|
[
|
||
|
|
({"category": "ppe"}, "category"),
|
||
|
|
({"category": "Detect.PPE"}, "category"),
|
||
|
|
({"image": b""}, "image"),
|
||
|
|
({"image": bytearray(b"image")}, "image"),
|
||
|
|
({"media_type": "application/octet-stream"}, "media_type"),
|
||
|
|
({"parameters": {"threshold": float("nan")}}, "parameters"),
|
||
|
|
({"parameters": {"payload": b"not-json"}}, "parameters"),
|
||
|
|
({"image_roles": "annotated"}, "image_roles"),
|
||
|
|
({"image_roles": ("annotated", "annotated")}, "image_roles"),
|
||
|
|
({"image_roles": ("Bad Role",)}, "image_roles"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_infer_rejects_invalid_local_arguments_without_http(
|
||
|
|
kwargs: dict[str, Any],
|
||
|
|
message: str,
|
||
|
|
) -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
http = FakeHttpClient()
|
||
|
|
arguments: dict[str, Any] = {
|
||
|
|
"category": "detect.ppe",
|
||
|
|
"image": b"image",
|
||
|
|
"media_type": "image/jpeg",
|
||
|
|
**kwargs,
|
||
|
|
}
|
||
|
|
with pytest.raises(DetectClientConfigurationError, match=message):
|
||
|
|
await DetectClient(
|
||
|
|
"http://detect.invalid", http_client=http
|
||
|
|
).infer(**arguments)
|
||
|
|
assert http.calls == []
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"base_url",
|
||
|
|
["", "detect.invalid", "ftp://detect.invalid", "http:///missing-host", "http://x/api"],
|
||
|
|
)
|
||
|
|
def test_client_rejects_invalid_base_url(base_url: str) -> None:
|
||
|
|
with pytest.raises(DetectClientConfigurationError, match="base_url"):
|
||
|
|
DetectClient(base_url, http_client=FakeHttpClient())
|
||
|
|
|
||
|
|
|
||
|
|
def test_http_status_error_preserves_status_and_safe_server_detail() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
client = DetectClient(
|
||
|
|
"http://detect.invalid",
|
||
|
|
http_client=FakeHttpClient(
|
||
|
|
FakeResponse(404, {"code": "unknown_category", "message": "not deployed"})
|
||
|
|
),
|
||
|
|
)
|
||
|
|
with pytest.raises(DetectHttpStatusError, match="not deployed") as captured:
|
||
|
|
await client.list_models()
|
||
|
|
assert captured.value.status_code == 404
|
||
|
|
assert captured.value.path == "/v1/models"
|
||
|
|
assert captured.value.error["code"] == "unknown_category"
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_transport_failure_is_wrapped_but_programming_errors_are_not() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport_client = DetectClient(
|
||
|
|
"http://detect.invalid",
|
||
|
|
http_client=FakeHttpClient(OSError("connection refused")),
|
||
|
|
)
|
||
|
|
with pytest.raises(DetectTransportError, match="connection refused"):
|
||
|
|
await transport_client.list_models()
|
||
|
|
|
||
|
|
broken_fake = DetectClient(
|
||
|
|
"http://detect.invalid",
|
||
|
|
http_client=FakeHttpClient(AssertionError("bad fake")),
|
||
|
|
)
|
||
|
|
with pytest.raises(AssertionError, match="bad fake"):
|
||
|
|
await broken_fake.list_models()
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"response",
|
||
|
|
[
|
||
|
|
FakeResponse(200, json_error=ValueError("invalid JSON")),
|
||
|
|
FakeResponse(200, []),
|
||
|
|
FakeResponse(200, {**_catalog_payload(), "unexpected": True}),
|
||
|
|
FakeResponse(
|
||
|
|
200,
|
||
|
|
{
|
||
|
|
**_catalog_payload(),
|
||
|
|
"active_push": _catalog_payload()["passive_invoke"],
|
||
|
|
},
|
||
|
|
),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_list_models_rejects_invalid_success_responses(response: FakeResponse) -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
client = DetectClient(
|
||
|
|
"http://detect.invalid", http_client=FakeHttpClient(response)
|
||
|
|
)
|
||
|
|
with pytest.raises(DetectProtocolError):
|
||
|
|
await client.list_models()
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("mismatch", ["request_id", "category"])
|
||
|
|
def test_infer_rejects_mismatched_response_identity(mismatch: str) -> None:
|
||
|
|
def mismatched_response(call: dict[str, Any]) -> FakeResponse:
|
||
|
|
response = _inference_response(call)
|
||
|
|
if mismatch == "request_id":
|
||
|
|
response._payload["request_id"] = "another-request"
|
||
|
|
else:
|
||
|
|
response._payload["category"] = "detect.mobile_phone"
|
||
|
|
return response
|
||
|
|
|
||
|
|
async def scenario() -> None:
|
||
|
|
client = DetectClient(
|
||
|
|
"http://detect.invalid",
|
||
|
|
http_client=FakeHttpClient(mismatched_response),
|
||
|
|
)
|
||
|
|
with pytest.raises(DetectProtocolError, match=mismatch):
|
||
|
|
await client.infer(
|
||
|
|
"detect.ppe", b"image", media_type="image/jpeg"
|
||
|
|
)
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_close_is_idempotent_does_not_close_injected_client_and_blocks_requests() -> None:
|
||
|
|
async def scenario() -> FakeHttpClient:
|
||
|
|
http = FakeHttpClient(FakeResponse(200, _catalog_payload()))
|
||
|
|
client = DetectClient("http://detect.invalid", http_client=http)
|
||
|
|
await client.aclose()
|
||
|
|
await client.aclose()
|
||
|
|
with pytest.raises(DetectClientClosedError):
|
||
|
|
await client.list_models()
|
||
|
|
return http
|
||
|
|
|
||
|
|
http = asyncio.run(scenario())
|
||
|
|
assert http.close_count == 0
|
||
|
|
assert http.calls == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"relative_path",
|
||
|
|
["configs/server_detect.yaml", "configs/server_gauge.yaml"],
|
||
|
|
)
|
||
|
|
def test_server_configs_use_the_root_app_config(relative_path: str) -> None:
|
||
|
|
config = load_config(PROJECT_ROOT / relative_path)
|
||
|
|
assert config.api_version == "cmvr.edge.ai/v1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_server_detect_config_routes_stable_categories_to_server_owned_models() -> None:
|
||
|
|
config = load_config(PROJECT_ROOT / "configs/server_detect.yaml")
|
||
|
|
|
||
|
|
assert config.server is not None
|
||
|
|
assert config.server.enabled is True
|
||
|
|
assert set(config.server.routes) == {"detect.ppe", "detect.mobile_phone"}
|
||
|
|
assert config.server.routes["detect.ppe"].model_id == (
|
||
|
|
"construction-ppe-yolov8@2"
|
||
|
|
)
|
||
|
|
assert config.server.routes["detect.mobile_phone"].model_id == (
|
||
|
|
"yolov8n-mobile-phone@2"
|
||
|
|
)
|
||
|
|
for category, route in config.server.routes.items():
|
||
|
|
assert route.pipeline in config.pipelines
|
||
|
|
pipeline = config.pipelines[route.pipeline]
|
||
|
|
detector = pipeline.nodes["detector"]
|
||
|
|
assert detector.params["model"] == route.model_id
|
||
|
|
assert detector.params["attach_frame"] is True
|
||
|
|
assert category not in detector.params
|
||
|
|
assert pipeline.nodes["request_source"].params["category"] == category
|
||
|
|
assert pipeline.nodes["response"].params["category"] == category
|
||
|
|
assert [edge.source for edge in pipeline.edges] == [
|
||
|
|
"request_source.requests",
|
||
|
|
"image_decoder.frames",
|
||
|
|
"detector.detections",
|
||
|
|
"response.responses",
|
||
|
|
]
|
||
|
|
assert [edge.target for edge in pipeline.edges] == [
|
||
|
|
"image_decoder.requests",
|
||
|
|
"detector.frames",
|
||
|
|
"response.detections",
|
||
|
|
"response_sink.responses",
|
||
|
|
]
|
||
|
|
assert all(
|
||
|
|
edge.qos.profile == "request"
|
||
|
|
and edge.qos.capacity == route.queue_capacity
|
||
|
|
and edge.qos.overflow == "block"
|
||
|
|
for edge in pipeline.edges
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_server_detect_config_compiles_both_passive_pipelines() -> None:
|
||
|
|
config = load_config(PROJECT_ROOT / "configs/server_detect.yaml")
|
||
|
|
registry = create_default_registry(discover_entry_points=False)
|
||
|
|
|
||
|
|
compiled = validate_application(config, registry)
|
||
|
|
|
||
|
|
assert {item.pipeline_id for item in compiled} == {
|
||
|
|
"detect_ppe",
|
||
|
|
"detect_mobile_phone",
|
||
|
|
}
|
||
|
|
for item in compiled:
|
||
|
|
assert set(item.plugin_specs) == {
|
||
|
|
"request_source",
|
||
|
|
"image_decoder",
|
||
|
|
"detector",
|
||
|
|
"response",
|
||
|
|
"response_sink",
|
||
|
|
}
|