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:
@@ -30,6 +30,10 @@ STATION_CODE=ST-DEFAULT
|
|||||||
# another station's recipes would silently break traceability.
|
# another station's recipes would silently break traceability.
|
||||||
STATION_SWITCH_ENABLED=0
|
STATION_SWITCH_ENABLED=0
|
||||||
|
|
||||||
|
# --- Vision ---
|
||||||
|
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||||
|
VISION_WORKER_URL=http://vision:8100
|
||||||
|
|
||||||
# --- File Storage ---
|
# --- File Storage ---
|
||||||
# Resolved against the project root in src/backend/config.py.
|
# Resolved against the project root in src/backend/config.py.
|
||||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
|
||||||
|
# needs libGL and glib at runtime even though it is never imported by name here.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
RUN uv sync --extra vision --frozen --no-dev
|
||||||
|
|
||||||
|
COPY src/vision/ ./src/vision/
|
||||||
|
COPY src/vision_worker/ ./src/vision_worker/
|
||||||
|
|
||||||
|
# Ruling R8: the version is stamped at build time, not discovered at runtime.
|
||||||
|
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
|
||||||
|
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
|
||||||
|
# outside the build context - so `git rev-parse HEAD` cannot work in here.
|
||||||
|
# Pass the submodule's commit in from the host, which does have git:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
# docker compose build vision
|
||||||
|
ARG VISION_ENGINE_VERSION
|
||||||
|
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||||
|
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Two workers, not four: each holds the vision stack in memory.
|
||||||
|
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
||||||
|
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
||||||
@@ -403,6 +403,51 @@ uv sync --extra vision --extra dev --python 3.13
|
|||||||
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Lo stesso vale per `src/vision_worker/tests/`, il piccolo servizio FastAPI che
|
||||||
|
espone il runner: dipende da `src.vision.runner` e quindi eredita lo stesso
|
||||||
|
vincolo su Python 3.13.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision --extra dev pytest src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Il worker di visione (`Dockerfile.vision`)
|
||||||
|
|
||||||
|
Il worker gira in un container separato dal server FastAPI principale, così
|
||||||
|
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
|
||||||
|
richieda di riavviare il traffico di produzione. Il container non pubblica
|
||||||
|
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
|
||||||
|
`http://vision:8100` sulla rete `tmflow-net`.
|
||||||
|
|
||||||
|
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
|
||||||
|
(`engine_version`), perché una stazione che misura con un motore diverso da
|
||||||
|
quello atteso deve poter essere identificata. Il container, però, non ha
|
||||||
|
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
|
||||||
|
submodule, e la copia `.git` che lo collega al repository ospitante non
|
||||||
|
viene inclusa nel contesto di build — quindi la versione non può essere
|
||||||
|
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
|
||||||
|
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
|
||||||
|
dove il repository Git è presente per intero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml build vision
|
||||||
|
```
|
||||||
|
|
||||||
|
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
|
||||||
|
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
|
||||||
|
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
|
||||||
|
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
|
||||||
|
ricade su `git rev-parse` se la variabile non è impostata — comodo per
|
||||||
|
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
|
||||||
|
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
|
||||||
|
il `.git` del submodule non arriva nel contesto di build, e il worker
|
||||||
|
risponde con un errore esplicito su `/health` invece di indovinare una
|
||||||
|
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
|
||||||
|
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||||
|
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||||
|
in produzione).
|
||||||
|
|
||||||
Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend).
|
Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend).
|
||||||
|
|
||||||
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||||
@@ -430,6 +475,7 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
|
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -41,6 +42,17 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
|
container_name: tmflow-vision
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -50,6 +51,17 @@ services:
|
|||||||
- tmflow-net
|
- tmflow-net
|
||||||
- traefik-net
|
- traefik-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
|
container_name: tmflow-vision
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
+1
-1
@@ -87,4 +87,4 @@ vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests"]
|
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
|
||||||
|
|||||||
+40
-2
@@ -6,6 +6,7 @@ database, no filesystem beyond reading the pinned submodule's commit.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -36,12 +37,49 @@ class RunOutcome:
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def engine_version() -> str:
|
def _engine_version_from_git() -> str:
|
||||||
"""The pinned VisionSuite commit: stamped on every number produced."""
|
"""Read the pinned commit from the submodule checkout.
|
||||||
|
|
||||||
|
Cached because the subprocess call is not free and the answer cannot
|
||||||
|
change within a run. Kept separate from `engine_version()` so that the
|
||||||
|
(cheap) environment-variable check below is never served a stale answer,
|
||||||
|
and so a test can force this path to fail without poisoning the cache
|
||||||
|
for real callers.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
return subprocess.check_output(
|
return subprocess.check_output(
|
||||||
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
||||||
text=True,
|
text=True,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
).strip()
|
).strip()
|
||||||
|
except (OSError, subprocess.CalledProcessError) as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"engine_version: cannot determine which VisionSuite commit this "
|
||||||
|
"process is running. VISION_ENGINE_VERSION is not set, and "
|
||||||
|
f"`git -C {_SUBMODULE} rev-parse HEAD` failed ({exc}). "
|
||||||
|
"In the vision worker container this is expected - it has no "
|
||||||
|
"usable .git for the vendored submodule - so the image must be "
|
||||||
|
"built with `--build-arg VISION_ENGINE_VERSION=<commit>` "
|
||||||
|
"(see Dockerfile.vision). In a development checkout, make sure "
|
||||||
|
"the vendor/visionsuite submodule is initialised. Refusing to "
|
||||||
|
"guess: a station must not measure with an engine it cannot name."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def engine_version() -> str:
|
||||||
|
"""The pinned VisionSuite commit: stamped on every number produced.
|
||||||
|
|
||||||
|
Resolution order: the VISION_ENGINE_VERSION environment variable (set at
|
||||||
|
container build time - Ruling R8, see Dockerfile.vision) if set and
|
||||||
|
non-empty; otherwise `git rev-parse HEAD` on the vendored submodule,
|
||||||
|
which works in a development checkout. Never falls back to a guess, an
|
||||||
|
empty string, or "unknown" - a station running an engine it cannot name
|
||||||
|
must refuse to measure rather than produce numbers nobody can trace.
|
||||||
|
"""
|
||||||
|
stamped = os.environ.get("VISION_ENGINE_VERSION", "").strip()
|
||||||
|
if stamped:
|
||||||
|
return stamped
|
||||||
|
return _engine_version_from_git()
|
||||||
|
|
||||||
|
|
||||||
def run_graph(
|
def run_graph(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pytest.importorskip(
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from src.vision import runner
|
||||||
from src.vision.runner import RunOutcome, engine_version, run_graph
|
from src.vision.runner import RunOutcome, engine_version, run_graph
|
||||||
|
|
||||||
|
|
||||||
@@ -74,3 +75,36 @@ def test_a_broken_tool_does_not_sink_the_others():
|
|||||||
def test_the_engine_says_which_version_it_is():
|
def test_the_engine_says_which_version_it_is():
|
||||||
version = engine_version()
|
version = engine_version()
|
||||||
assert len(version) == 40 # a full git SHA
|
assert len(version) == 40 # a full git SHA
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_version_honours_the_environment_variable(monkeypatch):
|
||||||
|
"""A build-stamped VISION_ENGINE_VERSION wins over git, if set (Ruling R8:
|
||||||
|
the container has no usable .git for the vendored submodule, so the
|
||||||
|
worker image stamps the commit at build time instead of discovering it)."""
|
||||||
|
stamped = "a" * 40
|
||||||
|
monkeypatch.setenv("VISION_ENGINE_VERSION", stamped)
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert engine_version() == stamped
|
||||||
|
finally:
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
||||||
|
"""No env var and no resolvable git HEAD: refuse loudly. An engine that
|
||||||
|
cannot say what it is has no business producing measurements - it must
|
||||||
|
never fall back to a guess, an empty string, or "unknown"."""
|
||||||
|
monkeypatch.delenv("VISION_ENGINE_VERSION", raising=False)
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
def fake_check_output(*args, **kwargs):
|
||||||
|
raise FileNotFoundError("git: command not found")
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner.subprocess, "check_output", fake_check_output)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError, match="VISION_ENGINE_VERSION"):
|
||||||
|
engine_version()
|
||||||
|
finally:
|
||||||
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""The heavy container: the runner behind an internal API.
|
||||||
|
|
||||||
|
Separate from the FastAPI server on purpose. The API image stays light, a
|
||||||
|
VisionSuite upgrade does not restart production traffic, and an execution that
|
||||||
|
crashes does not take the other tablets' requests down with it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.vision.runner import engine_version, run_graph
|
||||||
|
|
||||||
|
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok", "engine_version": engine_version()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/run")
|
||||||
|
async def run(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
graph: str = Form(...),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
parsed_graph = json.loads(graph)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"graph is not valid JSON: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
frame = np.array(Image.open(image.file).convert("L"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
outcome = run_graph(frame, parsed_graph)
|
||||||
|
except ValueError as exc:
|
||||||
|
# from_dict refuses a schema version it does not handle; that is a bad
|
||||||
|
# request, not a server fault.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outputs": outcome.outputs,
|
||||||
|
"failures": [
|
||||||
|
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
|
||||||
|
for f in outcome.failures
|
||||||
|
],
|
||||||
|
"engine_version": outcome.engine_version,
|
||||||
|
"duration_ms": outcome.duration_ms,
|
||||||
|
}
|
||||||
@@ -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