feat(vision): il worker che espone il runner, versione stampata al build
Container FastAPI separato (Dockerfile.vision, python:3.13-slim) che espone run_graph/engine_version del Task 2 via POST /run e GET /health, cosi' l'immagine del server principale non importa mai VisionSuite. engine_version() ora legge VISION_ENGINE_VERSION se impostata, altrimenti ricade su git rev-parse nel checkout di sviluppo, e non inventa mai un valore: senza nessuna delle due solleva un errore esplicito. Nel container il fallback a git non puo' funzionare (.git del submodule punta fuori dal build context), quindi Dockerfile.vision prende il commit come build arg e lo fissa in ambiente; i compose file lo passano da VISION_ENGINE_VERSION. Nessuna porta pubblicata e nessuna label Traefik sul servizio vision: e' raggiungibile solo dal server, su tmflow-net. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""The worker is a thin shell: it receives an image and a graph, and answers
|
||||
with numbers."""
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
# Same reasoning as src/vision/tests/test_runner.py: the vision tree (and
|
||||
# therefore this worker, which imports src.vision.runner) requires Python
|
||||
# >=3.13. This must run before `import numpy` / `from src.vision_worker.main
|
||||
# import app` so that `uv run pytest` on the default 3.11 environment reports
|
||||
# this module as skipped, with a reason, instead of erroring out of
|
||||
# collection.
|
||||
pytest.importorskip(
|
||||
"visionsuite.task",
|
||||
reason=(
|
||||
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
|
||||
"declare it upstream); run it with "
|
||||
"`uv run --python 3.13 --extra vision --extra dev pytest "
|
||||
"src/vision_worker/tests`"
|
||||
),
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from PIL import Image
|
||||
|
||||
from src.vision_worker.main import app
|
||||
|
||||
|
||||
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
|
||||
image = np.zeros((size, size), dtype=np.uint8)
|
||||
yy, xx = np.ogrid[:size, :size]
|
||||
centre = size // 2
|
||||
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
||||
buffer = io.BytesIO()
|
||||
Image.fromarray(image).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def a_circle_graph() -> dict:
|
||||
from visionsuite.task import Task
|
||||
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||
|
||||
task = Task(name="prova")
|
||||
found = task.add(DetectCircleTool(name="disco"))
|
||||
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||
found.scan_size = 30.0
|
||||
quota = task.add(DiameterTool(name="diametro"))
|
||||
quota.link("circle", found, "circle")
|
||||
return task.to_dict()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_says_which_engine_it_carries():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["engine_version"]) == 40
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_returns_the_named_outputs():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||
data={"graph": json.dumps(a_circle_graph())},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["failures"] == []
|
||||
assert any(key.endswith(".diameter") for key in body["outputs"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_malformed_graph_is_refused_not_crashed():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||
response = await client.post(
|
||||
"/run",
|
||||
files={"image": ("piece.png", a_disc_png(), "image/png")},
|
||||
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "99" in response.json()["detail"]
|
||||
Reference in New Issue
Block a user