Compare commits

..

14 Commits

Author SHA1 Message Date
Adriano 3899d5ecd9 Merge lavoro/daitarn-46-motore: percorsi con lettera di unità (#46) 2026-10-07 12:32:47 +02:00
Adriano f986c172c2 Worker: recipe_dir con lettera di unita rifiutato su ogni OS (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:32:34 +02:00
Adriano c1c7ae8d0e Merge lavoro/daitarn-46-operatore (#46) 2026-10-07 12:27:16 +02:00
Adriano cf49695140 Merge lavoro/daitarn-46-maker (#46) 2026-10-07 12:27:16 +02:00
Adriano c9e01a223b Merge lavoro/daitarn-46-backend (#46) 2026-10-07 12:27:15 +02:00
Adriano 3690c35c47 Merge lavoro/daitarn-46-motore (#46) 2026-10-07 12:27:15 +02:00
Adriano fc1902a1bb Ricetta: il digest della cartella copre anche i file di meta (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:04 +02:00
Adriano 86290274e5 Test ricetta v2, execute dxf_compare, migrazione 016 (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:15:02 +02:00
Adriano ee0f65ed39 Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:12:50 +02:00
Adriano 18d0db8a0c Migrazione 016 vision_recipe_dir, copy-on-write, dxf_compare tra i task di misura (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:08:29 +02:00
Adriano e640c4a36e Maker: ricetta visione e uscita per quota, tipi camera_measure/dxf_compare (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:55:31 +02:00
Adriano accf416cc9 Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:54:27 +02:00
Adriano 84a4b5f34d Regola submodule VisionSuite su origin/main (bc0df16), uv.lock (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:51:06 +02:00
Adriano 9238f8926b visionsuite bc0df16, vs-tools e vs-dxf nelle dipendenze (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:50:35 +02:00
33 changed files with 1699 additions and 256 deletions
+2
View File
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
VISION_RUN_TIMEOUT_S=100
# Quante esecuzioni di visione in parallelo accetta il worker.
VISION_MAX_CONCURRENCY=2
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
VISION_RECIPES_ROOT=/app/uploads
# --- Docker ---
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
+2
View File
@@ -289,6 +289,8 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
```bash
+3
View File
@@ -71,6 +71,9 @@ services:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
environment:
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
VISION_RECIPES_ROOT: /app/uploads
volumes:
- upload_data:/app/uploads:ro
healthcheck:
+4
View File
@@ -63,6 +63,8 @@ vision = [
"vs-task; python_version>='3.13'",
"vs-measure; python_version>='3.13'",
"vs-pm2d; python_version>='3.13'",
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
"vs-tools[locate,dxf]; python_version>='3.13'",
"numpy; python_version>='3.13'",
]
@@ -101,6 +103,8 @@ visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
[tool.pytest.ini_options]
asyncio_mode = "auto"
+115 -3
View File
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import io
import json
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
from src.backend.api.routers.files import (
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
)
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse,
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
@@ -126,7 +132,7 @@ async def execute(
) -> VisionExecuteResponse:
task = await _task_or_404(db, task_id)
result, saved = await vision_service.execute_task(
result, saved, report = await vision_service.execute_task(
db,
task,
await _read_image(image),
@@ -135,6 +141,7 @@ async def execute(
lot_number=lot_number,
serial_number=serial_number,
production_run_id=production_run_id,
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
)
await db.commit()
@@ -147,6 +154,111 @@ async def execute(
)
for m in saved
],
outcome=report.get("outcome"),
overlay=report.get("overlay") or [],
image_url=f"/api/vision/results/{result.id}/image",
failures=report.get("failures") or [],
)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> FileResponse:
"""The image a vision run measured, as saved by `execute` (#46)."""
result = (await db.execute(
select(VisionResult).where(VisionResult.id == result_id)
)).scalar_one_or_none()
if result is None or not result.image_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
)
return FileResponse(resolve_upload_path(result.image_path))
def _422(errors: list[str]) -> HTTPException:
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
)
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
recipe: UploadFile = File(...),
files: list[UploadFile] = File(default=[]),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeUploadResponse:
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
raw = await recipe.read()
if not validate_file_size(len(raw)):
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
try:
graph = json.loads(raw)
except ValueError:
raise _422(["the recipe is not valid JSON"])
if not isinstance(graph, dict):
raise _422(["the recipe must be a JSON object"])
uploads: list[tuple[str, bytes]] = []
for f in files:
content = await f.read()
if not validate_file_size(len(content)):
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
uploads.append((f.filename or "", content))
placed, errors = vision_service.match_recipe_files(graph, uploads)
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
recipe_dir=recipe_dir,
files=vision_service.list_recipe_files(recipe_dir),
outputs=check["outputs"],
)
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
async def get_recipe(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeResponse:
task = await _task_or_404(db, task_id)
outputs: list[dict] = []
if task.vision_json:
check = await vision_service.validate_recipe(
task.vision_json, task.vision_recipe_dir,
)
outputs = check.get("outputs", [])
return VisionRecipeResponse(
task_id=task.id,
recipe_dir=task.vision_recipe_dir,
recipe=task.vision_json,
files=vision_service.list_recipe_files(task.vision_recipe_dir),
outputs=outputs,
)
@@ -0,0 +1,30 @@
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
Percorso relativo sotto upload_path della cartella immutabile che contiene
recipe.json e i file di meta (DXF, modelli .npz, ...).
Revision ID: 016_vision_recipe_dir
Revises: 015_api_key_hash
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '016_vision_recipe_dir'
down_revision: Union[str, None] = '015_api_key_hash'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('vision_recipe_dir')
+28
View File
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
vision_result_id: int
engine_version: str
measurements: list[VisionMeasurementResult]
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
outcome: str | None = None
overlay: list[dict] = []
image_url: str | None = None
failures: list[dict] = []
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
quantity: str | None = None
unit: str | None = None
class VisionRecipeUploadResponse(BaseModel):
task_id: int
recipe_dir: str
files: list[str]
outputs: list[VisionRecipeOutput]
class VisionRecipeResponse(BaseModel):
task_id: int
recipe_dir: str | None = None
recipe: dict | None = None
files: list[str]
outputs: list[VisionRecipeOutput]
class VisionReferenceImageResponse(BaseModel):
+5 -1
View File
@@ -18,7 +18,8 @@ if TYPE_CHECKING:
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
# Types the operator screen treats as "there are quotes to take here".
MEASURING_TASK_TYPES = ("measure", "camera_measure")
# dxf_compare produce quote come camera_measure (#46)
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
class RecipeTask(Base):
@@ -51,6 +52,9 @@ class RecipeTask(Base):
# server routes it and never interprets it, which is what keeps VisionSuite
# out of this container.
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Relationships
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
+2
View File
@@ -115,6 +115,8 @@ async def _copy_tasks_to_version(
# a camera_measure task unable to execute from the moment a
# recipe with measurements is next edited.
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
)
db.add(new_task)
await db.flush() # get new_task.id
+214 -7
View File
@@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
"""
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
@@ -46,7 +47,13 @@ def _is_measurable(value: object) -> bool:
)
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
async def _call_worker(
image_bytes: bytes,
graph: dict,
*,
recipe_dir: str | None = None,
calibration: dict | None = None,
) -> dict:
"""The one place that talks to the worker; the tests replace it.
The worker's fault must not become the API's fault (spec's edge-case
@@ -60,12 +67,19 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
is not the worker's JSON contract must not turn into a bare KeyError
further down.
"""
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
# si mandano, il worker usa i suoi default.
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
if calibration:
data["calibration"] = json.dumps(calibration)
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)},
data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
@@ -122,6 +136,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
return report
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
refused) is folded into ok=False; transport faults become 502/504 as in
`_call_worker`. Replaced by the tests.
"""
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/validate", data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
errors = detail if isinstance(detail, list) else [str(detail)]
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: not valid JSON",
) from exc
if not isinstance(body, dict) or "ok" not in body:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing ok",
)
body.setdefault("errors", [])
body.setdefault("outputs", [])
return body
# Task types that run a vision graph (#46).
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
def _safe_join(root: Path, relative: str) -> Path:
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
candidate = Path(relative)
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
raise ValueError(f"unsafe path: {relative}")
full = (root / candidate).resolve()
if not full.is_relative_to(root.resolve()):
raise ValueError(f"unsafe path: {relative}")
return full
def collect_meta_paths(node: object) -> list[str]:
"""Every `path` string found in any dict under the recipe's `meta`."""
found: list[str] = []
if isinstance(node, dict):
for key, value in node.items():
if key == "path" and isinstance(value, str):
found.append(value)
else:
found.extend(collect_meta_paths(value))
elif isinstance(node, list):
for item in node:
found.extend(collect_meta_paths(item))
return found
def match_recipe_files(
recipe: dict, uploads: list[tuple[str, bytes]],
) -> tuple[dict[str, bytes], list[str]]:
"""Pair each uploaded file with a meta path by base name (#46).
Returns ({relative path: bytes}, errors). Errors list every upload that
has no name or matches no meta path, and every unsafe meta path. A meta
path left without a file is not an error here: /validate decides.
"""
paths = collect_meta_paths(recipe.get("meta", {}))
by_name: dict[str, str] = {}
for p in paths:
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
placed: dict[str, bytes] = {}
errors: list[str] = []
for name, content in uploads:
if not name:
errors.append("a file has no name")
continue
target = by_name.get(Path(name.replace("\\", "/")).name)
if target is None:
errors.append(f"file {name!r} matches no path in the recipe's meta")
continue
try:
_safe_join(Path("."), target)
except ValueError:
errors.append(f"meta path {target!r} is not allowed")
continue
placed[target] = content
return placed, errors
def store_recipe_folder(
task_id: int, recipe: dict, files: dict[str, bytes],
) -> str:
"""Write the immutable recipe folder; return its path relative to uploads.
Folder name = first 16 hex of the recipe's sha256. If it already exists it
is left untouched (immutable: other task versions may reference it).
"""
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
# perche' immutabile (#46).
h = hashlib.sha256(canonical.encode("utf-8"))
for rel in sorted(files):
h.update(b"\0" + rel.encode("utf-8") + b"\0")
h.update(hashlib.sha256(files[rel]).digest())
digest = h.hexdigest()[:16]
relative = f"vision/recipes/{task_id}/{digest}"
folder = settings.upload_path / relative
if folder.exists():
return relative
folder.mkdir(parents=True, exist_ok=True)
(folder / "recipe.json").write_text(
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
)
for rel, content in files.items():
target = _safe_join(folder, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
return relative
def list_recipe_files(recipe_dir: str | None) -> list[str]:
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
if not recipe_dir:
return []
try:
folder = _safe_join(settings.upload_path, recipe_dir)
except ValueError:
return []
if not folder.is_dir():
return []
return sorted(
p.relative_to(folder).as_posix()
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
)
def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]:
"""Save the executed image and its overlay JSON side by side (#46).
Returns (image_path, overlay_path), both relative to uploads.
"""
stem = uuid4().hex
base = Path("vision") / "results" / str(task_id)
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
image_rel = (base / f"{stem}.{image_ext}").as_posix()
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
(settings.upload_path / image_rel).write_bytes(image_bytes)
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
return image_rel, overlay_rel
async def execute_task(
db: AsyncSession,
task: RecipeTask,
@@ -132,8 +328,9 @@ async def execute_task(
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if task.task_type != "camera_measure":
image_ext: str = "png",
) -> tuple[VisionResult, list[Measurement], dict]:
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
@@ -210,7 +407,11 @@ async def execute_task(
),
)
report = await _call_worker(image_bytes, task.vision_json)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
@@ -231,8 +432,12 @@ async def execute_task(
),
)
overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
result = VisionResult(
task_id=task.id,
image_path=image_path,
overlay_path=overlay_path,
engine_version=report["engine_version"],
executed_on="server",
graph_snapshot=task.vision_json,
@@ -257,7 +462,7 @@ async def execute_task(
enforce_tolerance_gate=False,
))
return result, saved
return result, saved, report
def _reference_file_path(reference: VisionReferenceImage) -> Path:
@@ -377,4 +582,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
return await _call_worker(image_bytes, task.vision_json)
return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
+18
View File
@@ -131,3 +131,21 @@ class TestApiKeyHashMigration:
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
class TestVisionRecipeDirMigration:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
command.upgrade(cfg, "head")
assert "vision_recipe_dir" in cols()
command.downgrade(cfg, "015_api_key_hash")
assert "vision_recipe_dir" not in cols()
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_recipe_dir" in cols()
+2 -2
View File
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
def worker_says(monkeypatch):
"""The worker does not run in tests: its response is faked here."""
def _install(outputs: dict, failures: list | None = None):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {
"outputs": outputs,
"failures": failures or [],
@@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
)
await db_session.commit()
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict:
raise AssertionError("the worker must not run while the operator is blocked")
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
@@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr(
@@ -0,0 +1,282 @@
"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46)."""
import io
import json
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
RECIPE = {
"schema_version": 2, "name": "dxf", "tools": [],
"meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}},
}
OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}]
GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz"))
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.fixture
def validate_says(monkeypatch):
"""Il worker /validate non gira nei test: risposta finta."""
def _install(ok=True, errors=None, outputs=None):
async def fake(graph, recipe_dir):
return {
"ok": ok, "errors": errors or [],
"outputs": outputs if outputs is not None else OUTPUTS,
}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
return _install
def an_image() -> bytes:
buf = io.BytesIO()
Image.new("L", (32, 32)).save(buf, format="PNG")
return buf.getvalue()
async def make_task(db_session, user, task_type="dxf_compare"):
recipe = await create_test_recipe(db_session, user.id)
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
task.task_type = task_type
await db_session.commit()
return task, subtask
def upload(client, user, task, recipe=RECIPE, files=()):
return client.post(
f"/api/vision/tasks/{task.id}/recipe",
headers=auth_headers(user),
files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))]
+ [("files", (n, c, "application/octet-stream")) for n, c in files],
)
@pytest.mark.asyncio
async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["outputs"] == OUTPUTS
assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"]
assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/")
folder = settings.upload_path / body["recipe_dir"]
assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file()
await db_session.refresh(task)
assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"]
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
assert got.status_code == 200
assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS
@pytest.mark.asyncio
async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
assert got.json() == {
"task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
@pytest.mark.asyncio
async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")])
assert resp.status_code == 422
assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0]
await db_session.refresh(task)
assert task.vision_json is None and task.vision_recipe_dir is None
@pytest.mark.asyncio
async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}}
resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")])
assert resp.status_code == 422
assert not (settings.upload_path.parent / "evil.dxf").exists()
@pytest.mark.asyncio
async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user, task_type="measure")
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_invalid_json_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await client.post(
f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user),
files=[("recipe", ("r.json", b"{nope", "application/json"))],
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says):
validate_says(ok=False, errors=["meta file missing"])
task, _ = await make_task(db_session, maker_user)
task.vision_json = {"old": 1}
task.vision_recipe_dir = "vision/recipes/old"
await db_session.commit()
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 422
assert resp.json()["detail"] == ["meta file missing"]
await db_session.refresh(task)
assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old"
@pytest.mark.asyncio
async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
first = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf"
marker.write_bytes(b"CHANGED")
second = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
assert second["recipe_dir"] == first["recipe_dir"]
assert marker.read_bytes() == b"CHANGED"
@pytest.mark.asyncio
async def test_upload_requires_maker(
client, db_session, maker_user, measurement_tec_user, validate_says,
):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES)
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_execute_dxf_compare_saves_files_and_extended_response(
client, db_session, measurement_tec_user, monkeypatch,
):
task, subtask = await make_task(db_session, measurement_tec_user)
task.vision_json = RECIPE
task.vision_recipe_dir = "vision/recipes/1/abc"
subtask.vision_output = "cmp.points_in_pct"
await db_session.commit()
seen = {}
overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}]
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {
"outputs": {"cmp.points_in_pct": float(subtask.nominal)},
"failures": [], "engine_version": "e" * 40, "duration_ms": 5,
"overlay": overlay, "outcome": "OK",
}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await client.post(
"/api/vision/execute", headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen
assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == []
assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image"
assert body["measurements"][0]["pass_fail"] == "pass"
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert (settings.upload_path / result.image_path).read_bytes() == an_image()
assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay
img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user))
assert img.status_code == 200 and img.content == an_image()
@pytest.mark.asyncio
async def test_execute_dxf_compare_out_of_tolerance_fails(
client, db_session, measurement_tec_user, monkeypatch,
):
task, subtask = await make_task(db_session, measurement_tec_user)
task.vision_json = RECIPE
subtask.vision_output = "cmp.points_in_pct"
await db_session.commit()
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [],
"engine_version": "e" * 40, "outcome": "OK"}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await client.post(
"/api/vision/execute", headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code == 200
# il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite
assert resp.json()["measurements"][0]["pass_fail"] == "fail"
assert resp.json()["overlay"] == []
@pytest.mark.asyncio
async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user):
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.task_type = "dxf_compare"
task.vision_json = RECIPE
task.vision_recipe_dir = "vision/recipes/1/abc"
await db_session.flush()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
json={"change_notes": "cow"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2
assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE
@pytest.mark.asyncio
async def test_retry_with_missing_file_then_complete_succeeds(
client, db_session, maker_user, monkeypatch,
):
# worker finto: ok solo se il DXF esiste nella cartella della ricetta
async def fake(graph, recipe_dir):
present = (settings.upload_path / recipe_dir / "dxf" / "part.dxf").is_file()
return {"ok": present, "errors": [] if present else ["dxf missing"], "outputs": OUTPUTS}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
task, _ = await make_task(db_session, maker_user)
first = await upload(client, maker_user, task, files=[])
assert first.status_code == 422
second = await upload(client, maker_user, task, files=GOOD_FILES)
assert second.status_code == 200, second.text
+1 -1
View File
@@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "b" * 40, "duration_ms": 7}
monkeypatch.setattr(
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "c" * 40, "duration_ms": 5}
monkeypatch.setattr(
@@ -377,6 +377,47 @@ def api_upload_file():
return jsonify(resp), 201
# ============================================================================
# VISION RECIPE PROXY (#46)
# ============================================================================
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_vision_recipe(task_id: int):
"""Proxy: ricetta visione corrente del task (cartella, file, uscite)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_vision_recipe(task_id: int):
"""Proxy: carica ricetta .json + file allegati (multipart forward)."""
recipe = request.files.get("recipe")
if recipe is None or recipe.filename == "":
return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400
# Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files`
files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))]
for f in request.files.getlist("files"):
if f.filename:
files.append(("files", (f.filename, f.stream, f.content_type)))
resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files)
# Il 422 (errori di validazione) arriva al browser com'è, con `errors`
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
@@ -0,0 +1,80 @@
// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare.
// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks/<id>/recipe,
// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento.
// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js.
function visionRecipe(taskId, csrf, msgs) {
return {
taskId: taskId,
recipeDir: null,
files: [],
outputs: [],
errors: [], // errori 422 elencati in chiaro
loading: false,
busy: false,
recipeFile: null,
attachments: [],
msgs: msgs || {},
async init() {
// All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor
this.loading = true;
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`);
if (resp.ok) this._apply(await resp.json());
} catch (e) { console.error('visionRecipe init', e); }
this.loading = false;
},
// Aggiorna lo stato e avvisa il task editor (select vision_output delle quote)
_apply(data) {
this.recipeDir = data.recipe_dir || null;
this.files = data.files || [];
this.outputs = data.outputs || [];
window.dispatchEvent(new CustomEvent('vision-outputs', {
detail: { taskId: this.taskId, outputs: this.outputs }
}));
},
// Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci)
_errorsFrom(data) {
if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String);
const d = data.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x))));
if (d && typeof d === 'object') {
if (Array.isArray(d.errors)) return d.errors.map(String);
return [JSON.stringify(d)];
}
return [d || this.msgs.genericError || 'Error'];
},
async upload() {
if (!this.recipeFile) return;
this.busy = true;
this.errors = [];
try {
const fd = new FormData();
fd.append('recipe', this.recipeFile);
for (const f of this.attachments) fd.append('files', f);
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, {
method: 'POST',
headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf },
body: fd
});
const data = await resp.json();
if (!resp.ok || data.error) {
this.errors = this._errorsFrom(data);
} else {
this._apply(data);
this.recipeFile = null;
this.attachments = [];
}
} catch (e) {
this.errors = [this.msgs.genericError || String(e)];
}
this.busy = false;
},
onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; },
onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); }
};
}
@@ -277,6 +277,8 @@
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
</select>
<p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
@@ -460,6 +462,55 @@
<div class="tmf-card-body space-y-4">
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
<!-- Stato corrente: cartella, file, uscite -->
<template x-if="recipeDir">
<div class="text-xs space-y-1 mb-3">
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
<span class="font-mono" x-text="recipeDir"></span></p>
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
<span class="font-mono" x-text="files.join(', ')"></span></p>
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
<ul class="list-disc pl-5" x-show="outputs.length">
<template x-for="o in outputs" :key="o.key">
<li><span class="font-mono" x-text="o.key"></span>
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
</template>
</ul>
</div>
</template>
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
{{ _('Nessuna ricetta caricata') }}
</p>
<!-- Caricamento: ricetta .json + allegati -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
@change="onRecipe($event)">
</div>
<div>
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
</div>
</div>
<div class="flex justify-end mt-3">
<button type="button" class="btn btn-primary text-xs" @click="upload()"
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
</div>
<!-- Errori 422 in chiaro -->
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
</div>
</template>
<!-- ========== Task File Upload / Preview ========== -->
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
@@ -543,6 +594,8 @@
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
</select>
</div>
<div>
@@ -699,6 +752,18 @@
class="tmf-input"
placeholder="{{ _('Es. Diametro foro principale') }}">
</div>
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
<select x-model="newSubtask.vision_output" class="tmf-input">
<option value="">{{ _('Nessuna') }}</option>
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<!-- Measurement Type -->
<div>
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
@@ -1093,6 +1158,7 @@
{% block extra_js %}
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -1129,8 +1195,11 @@ function taskEditor() {
addingSubtaskForTask: null,
newSubtask: {
marker_number: 1, description: '', measurement_type: '',
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
vision_output: ''
},
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
visionOutputs: {},
// ---- Delete modals ----
deleteTaskModal: false,
@@ -1153,6 +1222,10 @@ function taskEditor() {
// Init
// ============================================================
init() {
// Uscite della ricetta visione: evento dal componente visionRecipe (#46)
window.addEventListener('vision-outputs', (ev) => {
this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] };
});
// Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
@@ -1610,7 +1683,8 @@ function taskEditor() {
uwl: null,
lwl: null,
ltl: null,
unit: 'mm'
unit: 'mm',
vision_output: ''
};
},
@@ -1634,7 +1708,8 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm'
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
};
try {
@@ -1687,7 +1762,8 @@ function taskEditor() {
uwl: subtask.uwl,
lwl: subtask.lwl,
ltl: subtask.ltl,
unit: subtask.unit || 'mm'
unit: subtask.unit || 'mm',
vision_output: subtask.vision_output || ''
};
},
@@ -1711,7 +1787,8 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm'
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
};
try {
@@ -13,15 +13,6 @@
barcodeInput: '',
barcodeLoading: false,
barcodeError: '',
/* #63/#64: ricetta scelta che richiede lotto/seriale; il pannello compare solo allora. */
selected: null,
needsTrace(recipe) {
return !!(recipe && (recipe.requires_lot || recipe.requires_serial));
},
choose(recipe) {
this.selected = recipe;
this.$nextTick(() => { const el = document.getElementById('trace-panel'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); });
},
get filteredRecipes() {
if (!this.search) return this.recipes;
const q = this.search.toLowerCase();
@@ -71,12 +62,9 @@
// The scanner is a way in like any other: a recipe that demands a lot
// demands it here too, or the rule would last exactly as long as it
// took someone to reach for the barcode reader.
// #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
// compare il pannello con i soli campi richiesti.
if (this.missingTraceability(data).length) {
this.barcodeModal = false;
this.barcodeInput = '';
this.choose(data);
const missing = this.missingTraceability(data);
if (missing.length) {
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', ');
return;
}
this.barcodeModal = false;
@@ -161,6 +149,50 @@
</div>
</div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ _('Numero Lotto') }}
</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}"
class="tmf-input font-mono">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}"
class="tmf-input font-mono">
</div>
</div>
</div>
</div>
<!-- Results Count -->
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-[var(--text-secondary)]">
@@ -237,26 +269,31 @@
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
rounded-b-xl">
{# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
(il pannello compare sotto la griglia); le altre partono subito. #}
<template x-if="needsTrace(recipe)">
<button type="button" @click="choose(recipe)"
:class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold">
{{ _('Seleziona') }}
</button>
</template>
<template x-if="!needsTrace(recipe)">
<a :href="buildTaskUrl(recipe.id)"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200 group-hover:shadow-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
{# What this recipe insists on before anyone measures. Said on the card
rather than after the fact: a measurement taken without its lot cannot
be traced back, and finding that out later is finding it out too late. #}
<template x-if="missingTraceability(recipe).length">
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
{{ _('AVVIA ATTIVITÀ') }}
</a>
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + missingTraceability(recipe).join(', ')"></span>
</p>
</template>
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
:aria-disabled="missingTraceability(recipe).length > 0"
:class="missingTraceability(recipe).length
? 'opacity-40 cursor-not-allowed pointer-events-none'
: 'group-hover:shadow-md'"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA IN SEQUENZA') }}
</a>
{# The whole list, for when the operator wants to see it or go back to a
task left unfinished. Deliberately the quiet one of the two. #}
<a :href="'/measure/tasks/' + recipe.id"
@@ -269,41 +306,6 @@
</template>
</div>
<!-- Traceability (#63/#64): compare solo dopo aver scelto una ricetta con
requires_lot / requires_serial, con i soli campi richiesti. -->
<div id="trace-panel" x-show="selected && needsTrace(selected)" x-cloak
class="tmf-card mt-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}: <span x-text="selected ? selected.name : ''"></span></h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div x-show="selected && selected.requires_lot">
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text" x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}" class="tmf-input font-mono">
</div>
<div x-show="selected && selected.requires_serial">
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text" x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}" class="tmf-input font-mono">
</div>
</div>
<a :href="selected && !missingTraceability(selected).length ? buildTaskUrl(selected.id) : null"
:aria-disabled="!selected || missingTraceability(selected).length > 0"
:class="(!selected || missingTraceability(selected).length) ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold mt-4">
{{ _('AVVIA ATTIVITÀ') }}
</a>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredRecipes.length === 0" x-cloak
class="text-center py-16">
@@ -115,12 +115,20 @@
</svg>
{{ _('Lista task') }}
</a>
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
<button @click="goToNextTask()"
:disabled="!canLeaveTask"
:title="canLeaveTask ? '' : blockedReason"
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
</button>
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask && productionRun">
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
<template x-if="isMeasureTask">
<div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #}
@@ -748,12 +756,10 @@
</div>
</template>
<template x-if="!isMeasureTask || subtasks.length === 0">
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
<button @click="goToNextTask()"
:disabled="!canLeaveTask"
class="btn text-lg font-bold flex-1 w-full min-h-[64px] justify-center gap-2 shadow-md
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
@@ -839,10 +845,6 @@
</div>
</div>
{# #61: se il server rifiuta l'avvio produzione, l'errore si vede qui, nell'overlay #}
<p x-show="productionError" x-cloak
class="mb-2 text-sm text-red-700 dark:text-red-300 text-center" x-text="productionError"></p>
<div class="flex gap-2">
<button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2">
@@ -1248,18 +1250,11 @@ function taskExecute() {
async loadProductionRun() {
try {
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
if (!resp.ok) {
// #61: si mostra il detail del server (es. 503 "Stazione non configurata"),
// che e' la causa vera; fallback generico se il corpo non e' JSON.
const errBody = await resp.json().catch(() => null);
this.productionError = (errBody && errBody.detail) || '{{ _("Errore di comunicazione con il server") }}';
return;
}
if (!resp.ok) return;
const run = await resp.json();
if (run && run.id) this.adoptProductionRun(run);
} catch (e) {
// #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
this.productionError = '{{ _("Errore di connessione") }}';
// Offline or server down: leave the page usable, just without the timer.
}
},
@@ -1772,9 +1767,13 @@ function taskExecute() {
this.showCompletionOverlay = false;
const opened = await this.startProduction();
if (!opened) {
// #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
// con l'errore (productionError) cosi' l'operatore puo' riprovare.
this.showCompletionOverlay = true;
// Server refused: keep the operator working rather than stranding them
// mid-shift, but productionError is on screen so it is not silent.
this.cycleConfirmed = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
return;
}
// The START-phase measurements have just been taken: record them as the first
@@ -1822,12 +1821,6 @@ function taskExecute() {
// ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) {
// #61: fermo/ripresa/fine produzione agiscono su un run: senza run aperto
// si dice subito, prima di chiedere le credenziali.
if (['fermo_linea', 'ripresa', 'fine_produzione'].includes(action) && !this.productionRun) {
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorAction = action;
this.supervisorUsername = '';
this.supervisorPassword = '';
@@ -104,7 +104,7 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA ATTIVITÀ') }}
{{ _('AVVIA IN SEQUENZA') }}
</a>
{% endif %}
{% endif %}
@@ -161,3 +161,82 @@ class TestVersionConflictProxy:
assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
class TestVisionRecipeProxy:
"""Proxy ricetta visione (#46): /maker/api/vision/tasks/<id>/recipe."""
def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"],
"outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}],
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={
"recipe": (io.BytesIO(b"{}"), "r.json"),
"files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")],
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct"
endpoint = mock_api_client.post.call_args.args[0]
sent = mock_api_client.post.call_args.kwargs["files"]
assert endpoint == "/api/vision/tasks/7/recipe"
# un campo `recipe` e un campo `files` per ogni allegato
assert [name for name, _f in sent] == ["recipe", "files", "files"]
def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data"
)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_upload_proxies_422(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "file mancante: a.dxf",
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={"recipe": (io.BytesIO(b"{}"), "r.json")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert "a.dxf" in resp.get_json()["detail"]
def test_get_recipe_proxies(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {
"task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe")
assert resp.status_code == 200
mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe")
class TestTaskEditorVisionUi:
"""task_editor.html espone i tipi visione e la select vision_output (#46)."""
def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client):
from tests.conftest import api_get_router
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": "dxf_compare", "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
assert 'value="camera_measure"' in html
assert 'value="dxf_compare"' in html
assert "visionRecipe(" in html
assert "vision_output" in html
assert "js/vision_recipe.js" in html
@@ -104,7 +104,7 @@ def test_the_selection_screen_starts_the_sequence(
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
html = _list_page(logged_in_client, mock_api_client)
assert "/measure/start/3" in html
assert "AVVIA ATTIVITÀ" in html
assert "AVVIA IN SEQUENZA" in html
# ---------------------------------------------------------------------------
@@ -163,32 +163,6 @@ def test_the_header_offers_completato_not_the_summary(
assert '@click="goToNextTask()"' in html
def test_only_one_completato_and_it_is_big(
logged_in_client, mock_api_client, monkeypatch,
):
"""#60: «Completato» una sola volta (in basso), largo e alto per il touch."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
# the label as a button text on its own line (comments in the JS also name it)
assert [l.strip() for l in html.splitlines()].count("Completato") == 1
assert "min-h-[64px]" in html
def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
logged_in_client, mock_api_client, monkeypatch,
):
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
apre e un rifiuto del server non avvia un timer locale finto."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-if="isMeasureTask && productionRun"' in html
assert "!this.productionRun" in html
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
assert "errBody.detail" in html
assert "Nessuna produzione aperta su questa stazione" in html
# il vecchio ripiego locale non c'e' piu'
assert "this.startCountdownFrom(this.measurementIntervalMinutes * 60)" not in html.split(
"async startProductionFromOverlay()")[1].split("async confirmCycle")[0]
def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client, monkeypatch,
):
@@ -222,27 +196,3 @@ def test_the_operator_flow_never_links_to_the_statistics(
]
for html in pages:
assert "/statistics" not in html
def test_trace_panel_only_for_recipes_that_require_it(
logged_in_client, mock_api_client, monkeypatch,
):
"""#63/#64: ricerca+griglia in alto, pannello lotto/seriale solo dopo la
scelta di una ricetta che lo richiede, con i soli campi richiesti."""
_italian(logged_in_client)
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "A", "name": "Con lotto", "requires_lot": True,
"requires_serial": False},
{"id": 2, "code": "B", "name": "Libera"},
]
html = logged_in_client.get("/measure/select").get_data(as_text=True)
assert 'id="trace-panel"' in html
assert 'x-show="selected && needsTrace(selected)"' in html
assert 'x-show="selected && selected.requires_lot"' in html
assert 'x-show="selected && selected.requires_serial"' in html
# la ricerca e la griglia precedono il pannello
assert html.index("Cerca ricetta") < html.index('id="trace-panel"')
assert html.index("Recipe Grid") < html.index('id="trace-panel"')
# le ricette senza obblighi partono direttamente
assert 'x-if="!needsTrace(recipe)"' in html
@@ -1862,8 +1862,8 @@ msgstr "No description available"
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA ATTIVITÀ"
msgstr "START ACTIVITY"
msgid "AVVIA IN SEQUENZA"
msgstr "START THE SEQUENCE"
# Maker - Task Editor
#: templates/measure/select_recipe.html:302
@@ -1864,8 +1864,8 @@ msgstr "Nessuna descrizione disponibile"
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA ATTIVITÀ"
msgstr "AVVIA ATTIVITÀ"
msgid "AVVIA IN SEQUENZA"
msgstr "AVVIA IN SEQUENZA"
# Maker - Task Editor
#: templates/measure/select_recipe.html:302
@@ -1825,7 +1825,7 @@ msgstr ""
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA ATTIVITÀ"
msgid "AVVIA IN SEQUENZA"
msgstr ""
#: templates/measure/select_recipe.html:302
+117 -22
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import math
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from functools import lru_cache
@@ -18,6 +19,9 @@ from typing import Any
import numpy as np
from visionsuite.core.image import Image
from visionsuite.task import Task
from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2
from visionsuite.task.registry import load_tools
from visionsuite.task.session import Session
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
@@ -49,6 +53,10 @@ class RunOutcome:
# Kept apart from `failures`: the tool did not raise, it just produced a
# number the caller must not measure a part by. Value is the reason.
invalid_outputs: dict[str, str] = field(default_factory=dict)
# #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito
# del TaskReport (informativo: il verdetto quote lo dà il backend).
overlay: list[dict] = field(default_factory=list)
outcome: str | None = None
@lru_cache(maxsize=1)
@@ -97,12 +105,46 @@ def engine_version() -> str:
return _engine_version_from_git()
def _scale_calibration(calibration: dict | None):
"""Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di
quota convertono con `in_millimetres`, che non accetta un dict)."""
if calibration is None:
return None
from visionsuite.calibration import ScaleCalibration
if not isinstance(calibration, dict) or not (
calibration.get("mm_per_px") or calibration.get("px_per_mm")
):
raise GraphBuildError(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm"
return ScaleCalibration(**{key: float(calibration[key])})
def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None,
workdir: str) -> Session:
"""Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError."""
cal = _scale_calibration(calibration)
try:
return Session.from_dict(
graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal,
)
except RecipeError as exc:
raise GraphBuildError(f"malformed graph: {exc}") from exc
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
def run_graph(
image: Any,
graph: dict,
calibration: dict | None = None,
recipe_dir: Any = None,
) -> RunOutcome:
"""Run a serialised vs-task graph against one image.
"""Run a serialised vs-task v2 graph against one image.
A tool that fails does not stop the others - on a crooked piece an edge may
simply not be there, and the other quotes are still wanted. Failures come
@@ -112,33 +154,35 @@ def run_graph(
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
actually read via `.pixels`; an `Image` passed in is used as-is.
Two phases, on purpose: building the task from `graph` can fail on
nothing but the caller's input (a bad schema version, a tool with no
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
a 422 for the worker. Once the task exists, `task.run()` is the engine's
own business; an exception from there is not the caller's mistake, and
the worker must not call it one.
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
...); without it an empty temporary folder is used, so a recipe that needs
a file fails as a malformed graph instead of reading from the CWD.
Two phases, on purpose: building the session can fail on nothing but the
caller's input (a bad schema version, a tool with no "kind", a missing
file) - that is `GraphBuildError`, a 422 for the worker. Once it exists,
running is the engine's own business; an exception from there is not the
caller's mistake, and the worker must not call it one.
"""
try:
task = Task.from_dict(graph)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
context: dict[str, Any] = {"image": wrapped_image}
if calibration is not None:
context["calibration"] = calibration
started = time.monotonic()
report = task.run(context=context)
elapsed_ms = int((time.monotonic() - started) * 1000)
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
with tempfile.TemporaryDirectory() as empty_dir:
session = _open_session(graph, recipe_dir, calibration, empty_dir)
wrapped_image = (
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
)
started = time.monotonic()
report = session.run(wrapped_image)
elapsed_ms = int((time.monotonic() - started) * 1000)
outputs: dict[str, float] = {}
failures: list[ToolFailure] = []
invalid_outputs: dict[str, str] = {}
overlay: list[dict] = []
for result in report:
# #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto
for item in result.overlay or []:
if isinstance(item, dict):
overlay.append({**item, "tool_id": result.tool_id})
if not result.ok:
failures.append(ToolFailure(
tool_id=result.tool_id,
@@ -164,4 +208,55 @@ def run_graph(
engine_version=engine_version(),
duration_ms=elapsed_ms,
invalid_outputs=invalid_outputs,
overlay=overlay,
outcome=report.outcome,
)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def validate_graph(
graph: dict, recipe_dir: Any = None
) -> tuple[bool, list[str], list[dict]]:
"""Validate a v2 recipe without an image: (ok, errors, numeric outputs).
Not a draft: a missing meta file is an error. `outputs` lists the numeric
outputs the tools declare (OUT properties with a numeric default, plus the
`values` of dimension tools), with quantity/unit when declared.
"""
load_tools()
if not isinstance(graph, dict):
return False, ["graph must be a JSON object"], []
try:
task = Task.from_dict(
graph, recipe_dir=Path(recipe_dir) if recipe_dir else None
)
except RecipeError as exc:
return False, list(exc.errors), []
except (KeyError, TypeError, AttributeError, ValueError) as exc:
return False, [f"{type(exc).__name__}: {exc}"], []
outputs: list[dict] = []
for tool in task.tools:
value_quantities = getattr(tool, "value_quantities", None) or {}
values = tuple(getattr(tool, "values", ()) or ())
for prop in tool.outputs():
if not (_is_number(prop.default) or prop.name in values):
continue
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
if not isinstance(quantity, str):
quantity = None
unit = None
if quantity == "length":
nominal_unit = getattr(tool, "nominal_unit", None)
unit = nominal_unit if isinstance(nominal_unit, str) else None
elif quantity == "angle":
unit = "deg"
outputs.append({
"key": f"{tool.id}.{prop.name}",
"quantity": quantity,
"unit": unit,
})
return True, [], outputs
+146 -13
View File
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
def a_circle_graph() -> dict:
"""A two-tool graph, built with vs-task's own API."""
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found = task.add(DetectCircle(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 = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota.link("circle", found, "circle")
return task.to_dict()
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
graph = a_circle_graph()
graph["tools"].append(
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
"values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
)
outcome = run_graph(a_disc(), graph)
@@ -81,14 +83,21 @@ class _FakeResult:
self.ok = ok
self.outputs = outputs or {}
self.error = error
self.overlay = [] # #46: v2, ogni risultato porta l'overlay
class _FakeReport(list):
"""#46: run_graph ora legge `.outcome` dal report di Session.run."""
outcome = "pass"
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
revisione) - a NaN must not become a quote's value (#16)."""
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -97,9 +106,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch):
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -109,9 +118,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
"""bool is a subclass of int - a flag output must not become a quote."""
def fake_run(self, context=None):
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
engine_version()
finally:
runner._engine_version_from_git.cache_clear()
# ---------------------------------------------------------------------------
# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF.
# ---------------------------------------------------------------------------
import hashlib
import shutil
from pathlib import Path
import cv2
_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite"
_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf"
# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm.
_PX_PER_MM = 4.0
def _dxf_scene() -> np.ndarray:
"""Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm."""
margin = 60.0
w = int((200.0 + 2 * margin) * _PX_PER_MM)
h = int((100.0 + 2 * margin) * _PX_PER_MM)
img = np.full((h, w), 255, dtype=np.uint8)
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1)
cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1)
return img
def _dxf_recipe() -> dict:
"""CompareDxf con ancora a due punti, collegati a due DetectPointScan.
Le due scansioni trovano i bordi sinistro e destro del pezzo una riga
sopra il lato in basso: origine del disegno = angolo in basso a sinistra,
direzione a -> b = asse x del disegno.
"""
h, w = _dxf_scene().shape
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
row = cy + hh - 1.0
def scan(tid: str, col: float) -> dict:
return {"id": tid, "name": tid, "kind": "DetectPointScan",
"values": {"start_row": row, "start_col": col - 30.0,
"end_row": row, "end_col": col + 30.0,
"width": 5.0, "select": "first"},
"links": {}}
return {
"schema_version": 2,
"name": "dxf",
"meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2)
"path": "disegno.dxf",
"sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}},
"tools": [
scan("t-a", cx - hw),
scan("t-b", cx + hw),
{"id": "t-dxf", "name": "confronto", "kind": "CompareDxf",
"values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM,
"anchor": "two_points",
"band_plus": 8.0, "band_minus": 8.0},
"links": {"point_a": {"tool": "t-a", "property": "point"},
"point_b": {"tool": "t-b", "property": "point"}}},
],
}
def test_a_circle_recipe_returns_overlay_and_outcome():
outcome = run_graph(a_disc(), a_circle_graph())
assert outcome.outcome in ("pass", "warning", "fail")
assert outcome.overlay, "DetectCircle must draw something"
assert all("tool_id" in item for item in outcome.overlay)
def test_calibration_dict_gives_mm_quotes():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5})
assert outcome.failures == []
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value"))
assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px
def test_compare_dxf_two_points_with_recipe_dir(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
assert outcome.failures == []
assert outcome.outputs["t-dxf.n_points"] > 1000
assert outcome.outputs["t-dxf.points_in_pct"] > 95.0
roles = {item.get("role") for item in outcome.overlay}
assert {"reference", "found"} <= roles
assert "t-dxf" in {item["tool_id"] for item in outcome.overlay}
def test_missing_meta_file_is_a_graph_build_error(tmp_path):
with pytest.raises(runner.GraphBuildError):
run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
def test_validate_lists_numeric_outputs(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
graph = _dxf_recipe()
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
assert ok, errors
keys = {o["key"] for o in outputs}
assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys
assert all(set(o) == {"key", "quantity", "unit"} for o in outputs)
def test_validate_reports_a_missing_file(tmp_path):
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
assert not ok and errors and outputs == []
+109 -17
View File
@@ -10,12 +10,20 @@ import asyncio
import io
import json
import os
import re
from pathlib import Path
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph
from src.vision.runner import (
GraphBuildError,
RunOutcome,
engine_version,
run_graph,
validate_graph,
)
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@@ -31,6 +39,69 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads,
# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice.
_RECIPES_ROOT_DEFAULT = "/app/uploads"
def _unprocessable(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail
)
def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None:
"""Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta.
Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None.
"""
if recipe_dir is None or not recipe_dir.strip():
return None
rel = recipe_dir.strip()
# Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo.
root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve()
if (
Path(rel).is_absolute()
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
or re.match(r"^[A-Za-z]:", rel)
or rel.startswith(("/", "\\"))
or ".." in Path(rel.replace("\\", "/")).parts
):
raise _unprocessable("recipe_dir must be a relative path under the recipes root")
resolved = (root / rel).resolve()
if resolved != root and root not in resolved.parents:
raise _unprocessable("recipe_dir is outside the recipes root")
return resolved
def _parse_graph(graph: str) -> dict:
try:
parsed = json.loads(graph)
except json.JSONDecodeError as exc:
raise _unprocessable(f"graph is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise _unprocessable(
f"graph must be a JSON object, got {type(parsed).__name__}"
)
return parsed
def _parse_calibration(calibration: str | None) -> dict | None:
if calibration is None or not calibration.strip():
return None
try:
parsed = json.loads(calibration)
except json.JSONDecodeError as exc:
raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict) or not (
parsed.get("mm_per_px") or parsed.get("px_per_mm")
):
raise _unprocessable(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
return parsed
class _ImageDecodeError(Exception):
"""The upload is not a decodable image - raised inside the worker thread
so it survives the trip through `asyncio.to_thread`."""
@@ -44,7 +115,12 @@ async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
def _decode_and_run(
content: bytes,
graph: dict,
calibration: dict | None = None,
recipe_dir: Path | None = None,
) -> RunOutcome:
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
and therefore `/health` - stays responsive while it executes. Once this
@@ -56,27 +132,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return run_graph(frame, graph)
return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir)
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
recipe_dir: str | None = Form(None),
calibration: str | None = Form(None),
) -> 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
if not isinstance(parsed_graph, dict):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}",
)
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
parsed_calibration = _parse_calibration(calibration)
content = await image.read()
@@ -89,7 +157,13 @@ async def run(
async with _run_slots:
try:
outcome = await asyncio.wait_for(
asyncio.to_thread(_decode_and_run, content, parsed_graph),
asyncio.to_thread(
_decode_and_run,
content,
parsed_graph,
parsed_calibration,
resolved_dir,
),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
@@ -130,4 +204,22 @@ async def run(
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
"invalid_outputs": outcome.invalid_outputs,
# #46: contorni per l'overlay sull'immagine e esito VisionSuite.
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
@app.post("/validate")
async def validate(
graph: str = Form(...),
recipe_dir: str | None = Form(None),
) -> dict:
"""Validates a v2 recipe (not a draft: a missing meta file is an error)
and lists its numeric outputs. No image, so it is cheap: no run slot."""
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
ok, errors, outputs = await asyncio.to_thread(
validate_graph, parsed_graph, resolved_dir
)
return {"ok": ok, "errors": errors, "outputs": outputs}
+94 -7
View File
@@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
def a_circle_graph() -> dict:
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found = task.add(DetectCircle(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 = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota.link("circle", found, "circle")
return task.to_dict()
@@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused():
@pytest.mark.asyncio
async def test_a_tool_without_a_kind_is_refused_not_crashed():
graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]}
graph = {"schema_version": 2, "name": "p", "tools": [{"id": "a", "name": "x"}]}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
@@ -151,7 +153,7 @@ async def test_a_tool_without_a_kind_is_refused_not_crashed():
assert response.status_code == 422
assert "malformed graph" in response.json()["detail"].lower()
assert "kind" in response.json()["detail"].lower()
assert "tipo sconosciuto" in response.json()["detail"].lower() # #46: messaggio v2
# ---------------------------------------------------------------------------
@@ -167,7 +169,7 @@ async def test_a_run_past_its_budget_answers_504_and_health_still_answers(
):
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 0.1)
def slow_run_graph(frame, graph):
def slow_run_graph(frame, graph, **kwargs):
time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread
return runner.RunOutcome(engine_version="a" * 40)
@@ -197,7 +199,7 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
monkeypatch.setattr(worker_main, "_run_slots", asyncio.Semaphore(1))
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0)
def slow_run_graph(frame, graph):
def slow_run_graph(frame, graph, **kwargs):
time.sleep(0.3)
return runner.RunOutcome(engine_version="a" * 40)
@@ -222,3 +224,88 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
assert second_response.status_code == 503
assert first_response.status_code == 200
# ---------------------------------------------------------------------------
# #46: recipe_dir, calibration, overlay, /validate.
# ---------------------------------------------------------------------------
async def _post(path: str, **kwargs):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(path, **kwargs)
@pytest.mark.asyncio
async def test_run_returns_overlay_and_outcome():
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
body = response.json()
assert response.status_code == 200
assert body["overlay"] and "tool_id" in body["overlay"][0]
assert body["outcome"] in ("pass", "warning", "fail")
@pytest.mark.asyncio
async def test_run_accepts_a_calibration():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(graph),
"calibration": json.dumps({"px_per_mm": 2.0})},
)
body = response.json()
assert response.status_code == 200, body
assert body["failures"] == []
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", ["../etc", "a/../../b", "/abs/path", "C:/x/y"])
async def test_a_recipe_dir_outside_the_root_is_refused(bad, monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad},
)
assert response.status_code == 422
response = await _post(
"/validate", data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_validate_ok_and_with_a_missing_file(monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post("/validate", data={"graph": json.dumps(a_circle_graph())})
body = response.json()
assert response.status_code == 200 and body["ok"] is True
assert any(o["key"].endswith(".diameter") for o in body["outputs"])
(tmp_path / "r1").mkdir()
graph = {
"schema_version": 2, "name": "d",
"meta": {"dxf": {"d": {"path": "mancante.dxf"}}},
"tools": [{"id": "t", "name": "c", "kind": "CompareDxf",
"values": {"dxf": "d", "mm_per_px": 0.25,
"band_plus": 8.0, "band_minus": 8.0},
"links": {}}],
}
response = await _post(
"/validate", data={"graph": json.dumps(graph), "recipe_dir": "r1"}
)
body = response.json()
assert response.status_code == 200
assert body["ok"] is False and body["errors"] and body["outputs"] == []
Generated
+142 -22
View File
@@ -626,6 +626,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
]
[[package]]
name = "ezdxf"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fonttools" },
{ name = "numpy" },
{ name = "pyparsing" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d0/bc515b7f5596bc3d55b93fc9f2c2b4926e2587af6bfae0486ef7cc7ddf43/ezdxf-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39aadab5bc7a05deddbd0db9b984c8a4bd28bd4c1ac2c5ee03f0d1b096272a88", size = 3553590, upload-time = "2026-05-14T09:25:28.042Z" },
{ url = "https://files.pythonhosted.org/packages/a7/73/207132f13a5280d186f2441d9125639fdc7ddeabf56c2d5da4d691e81ce6/ezdxf-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2ed65b191113a82133ba47daa6ebdd540b487b17394b1e327e9511dde87ad924", size = 3008276, upload-time = "2026-05-14T09:25:29.424Z" },
{ url = "https://files.pythonhosted.org/packages/82/73/b01af87269726e3719abc5ff694b843b710ef8fc345d21013e90d70e44d8/ezdxf-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c3c8f2c2b62fe5b6c9b62ab8257b697535d62b0c79acf3a5040bd95edfab451", size = 3001750, upload-time = "2026-05-14T09:25:31.146Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/284d7ca6a9da107966c7bd9d81c92245985235bc432fe0837982ef80630a/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d68d5ab9e1071319030da3af27590f34c38c8544a5ea638d9a4a800a96abb3c2", size = 5802382, upload-time = "2026-05-14T09:27:28.462Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fe/e4f37730ca5b5bf7de3a968a26126b404f60324d0b8ae9d59d11d6e55740/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f75a4f2924ebdda0f5b2779ff2135ba92de2596c95a8fa9b1d9ebcabea1be41", size = 5794671, upload-time = "2026-05-14T09:27:09.867Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3f/c3e7ab74f58cb69ef985fb71eda859b5c287cc506c65cbffe7e55c7cf2d4/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0eec8a2095b645420a67560e99041c0d25afefd39eab5bff8c736fdb32211a75", size = 5767366, upload-time = "2026-05-14T09:27:30.108Z" },
{ url = "https://files.pythonhosted.org/packages/66/7a/b7070bf05e75b3e709a6fe5ae5bcd3bd899e987ef84ff25cf28327519c03/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d87a85052e92b9c28e54605dbad65f779a3b61e6288e992d6ef00df7bde4bed3", size = 5830221, upload-time = "2026-05-14T09:27:12.074Z" },
{ url = "https://files.pythonhosted.org/packages/78/cf/a990289808c261c52f5b219484d136a8748773c99a6d25c84f55a0353fea/ezdxf-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:473d68c8f2b04ace513efa290a1cd0d3a4a589cdff48124f015f0e51034668b2", size = 2957599, upload-time = "2026-05-14T09:23:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/98648d221ffa047b5ba898c8e152010861dc71a66b531dcf15053075010e/ezdxf-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d723d35d08223318c17080a32a05192a1449e544dee867c9d9c6f25998fac303", size = 3563916, upload-time = "2026-05-14T09:25:32.879Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f1/84bbaa571412f840c22deeae185e54d4a67aa52f03e1728fd8425748c548/ezdxf-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b0c73b46085521bdbd4a3e7d88998da7bec5713ba89bbb502fdeffe6b42fc7e", size = 3016857, upload-time = "2026-05-14T09:25:34.607Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ff/f2eb77d22d26aa98abbe0961470871d8b85ef4e73e5527f544d25f958d6e/ezdxf-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac3e83a70c1f866dd5114192a6fab6e75cc83b75e907bc08c5d94b98cce14cc0", size = 3003336, upload-time = "2026-05-14T09:25:35.947Z" },
{ url = "https://files.pythonhosted.org/packages/65/7d/2a6ddd6beef5d9a8593078619f3920a0cd76b1db4c6a974ac37045543219/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ebe09717ab6636bf48780eabdab51e3ab2135f7963bb52452444b6d95e03d12", size = 5788485, upload-time = "2026-05-14T09:27:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932", size = 5816781, upload-time = "2026-05-14T09:27:13.96Z" },
{ url = "https://files.pythonhosted.org/packages/19/8d/669b8047cca162204c53b874183d73b5f6c4b1d5d48fa1e515a32d56f5ac/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02a097794625e9fc948e637dfe07057e98167b4fe215357a59645759fef6db3f", size = 5754006, upload-time = "2026-05-14T09:27:33.858Z" },
{ url = "https://files.pythonhosted.org/packages/5b/44/b753997173327250e43ae50f11f0c69016b1e78482e0bd3857c3495236e7/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f093bd2228f5782d483aed3bd89cdb74b672b5f90c677c4125cd236ee5649151", size = 5861860, upload-time = "2026-05-14T09:27:15.758Z" },
{ url = "https://files.pythonhosted.org/packages/0b/99/2e81cb168e0f09adfb05fb40e5f890ff87ac2c00e98079cf09837dd76330/ezdxf-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:3e518d9ed4dcbe525d57f14c25c5e592f82660a690fdec39b03b092e6cdc4771", size = 2963238, upload-time = "2026-05-14T09:23:47.924Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -1079,8 +1118,8 @@ name = "numba"
version = "0.67.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "llvmlite" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" }
wheels = [
@@ -1185,7 +1224,7 @@ name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
@@ -1570,6 +1609,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
]
[[package]]
name = "pypdfium2"
version = "5.8.0"
@@ -1751,7 +1799,7 @@ name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
@@ -1980,6 +2028,7 @@ vision = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
vision-worker = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
@@ -1991,6 +2040,7 @@ vision-worker = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
[package.metadata]
@@ -2032,6 +2082,7 @@ requires-dist = [
{ name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-tools" },
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
]
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
@@ -2157,8 +2208,14 @@ name = "visionsuite"
version = "0.0.1"
source = { editable = "vendor/visionsuite/packages/vs-core" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
]
[package.optional-dependencies]
dxf = [
{ name = "ezdxf" },
]
[package.metadata]
@@ -2168,42 +2225,70 @@ requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
]
provides-extras = ["dev", "fast", "dxf"]
[[package]]
name = "vs-dxf"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-dxf" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pydantic" },
{ name = "scipy" },
{ name = "visionsuite", extra = ["dxf"] },
{ name = "vs-measure" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=1.26" },
{ name = "opencv-python", specifier = ">=4.9" },
{ name = "pydantic", specifier = ">=2.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "scipy", specifier = ">=1.12" },
{ name = "visionsuite", extras = ["dxf"], editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
]
provides-extras = ["dev"]
[[package]]
name = "vs-measure"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-measure" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "scipy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "visionsuite" },
]
[package.metadata]
requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "scipy", specifier = ">=1.11" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "fast"]
[[package]]
name = "vs-pm2d"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
dependencies = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
{ name = "numba", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "pillow", marker = "python_full_version >= '3.13'" },
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "fastapi" },
{ name = "numba" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "visionsuite" },
]
[package.metadata]
@@ -2226,19 +2311,54 @@ name = "vs-task"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-task" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "visionsuite" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.132" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-stat", marker = "extra == 'stat'", editable = "vendor/visionsuite/packages/vs-stat" },
]
provides-extras = ["dev", "stat", "server"]
[[package]]
name = "vs-tools"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-tools" }
dependencies = [
{ name = "numpy" },
{ name = "visionsuite" },
{ name = "vs-measure" },
{ name = "vs-task" },
]
[package.optional-dependencies]
dxf = [
{ name = "vs-dxf" },
]
locate = [
{ name = "vs-pm2d" },
]
[package.metadata]
requires-dist = [
{ name = "ezdxf", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-dxf", marker = "extra == 'dxf'", editable = "vendor/visionsuite/packages/vs-dxf" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" },
]
provides-extras = ["dev"]
provides-extras = ["locate", "dxf", "dev"]
[[package]]
name = "watchfiles"