Compare commits

..

8 Commits

Author SHA1 Message Date
Adriano 9f2d624b4f Worker: endpoint /recipes/dxf-compare con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:40:08 +02:00
Adriano 9fe82d7cef Costruttore ricetta DXF compare dal campo, con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:39:21 +02:00
Adriano ae2353f7f8 Pacchetto Windows: registra gli strumenti senza entry point e controlla il catalogo nel build (#82)
rilascio-windows / setup-windows (push) Successful in 32m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMYrdz6uBbuwbaQ9MqDmMc
2026-10-19 23:03:48 +02:00
Adriano d652891c1c Merge lavoro/daitarn-tmf-81 (TieMeasureFlow#81)
rilascio-windows / setup-windows (push) Successful in 34m11s
2026-10-17 14:25:56 +02:00
Adriano 0b22643d69 Merge lavoro/daitarn-tmf-80 (TieMeasureFlow#80) 2026-10-17 14:25:56 +02:00
Adriano 15e31e2b8a Merge lavoro/daitarn-tmf-79 (TieMeasureFlow#79) 2026-10-17 14:25:55 +02:00
Adriano f33415df6a Test frontend: niente piu' dipendenza dall'ordine sulla stazione (TieMeasureFlow#79)
test_measure.py non ricarica piu' config/blueprints.measure (monkeypatch.setattr,
annullato al teardown); TestTaskComplete e test_recipe_rules_ui.py fissano la
stazione esplicitamente con una fixture autouse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:13:15 +02:00
Adriano 62b5e412ca static del frontend sempre LF (.gitattributes): impronte di VERSIONS.md e pacchetto Windows identici (TieMeasureFlow#81)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:10:39 +02:00
12 changed files with 698 additions and 27 deletions
+3
View File
@@ -0,0 +1,3 @@
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
src/frontend/flask_app/static/** text=auto eol=lf
+3
View File
@@ -73,3 +73,6 @@ docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages # Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/ .superpowers/
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
+1
View File
@@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni. - ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench. - ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`. - ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo. - ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet. - ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe. - ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
+59
View File
@@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`. con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
automatiche; NON salta il controllo di licenza) e `--senza-browser`. automatiche; NON salta il controllo di licenza) e `--senza-browser`.
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
ne' database; registra gli strumenti, scrive su stdout e in
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
La usa build.ps1 prima di Inno Setup.
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic. Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
@@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono ⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni. la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss). ⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ctypes import ctypes
import importlib
import json import json
import logging import logging
import os import os
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
pass pass
# ---------------------------------------------------------------- strumenti di visione
def registra_strumenti() -> list[str]:
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
di questo processo: registrare una volta sola qui vale per tutti."""
from visionsuite.task.registry import load_tools
load_tools()
try:
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
except ImportError:
MODULI = () # da sorgente: gli entry point di load_tools() bastano
falliti: list[str] = []
for modulo in MODULI:
try:
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
log.error("strumenti %s non importati", modulo, exc_info=True)
falliti.append(modulo)
return falliti
def controlla_strumenti() -> int:
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
from visionsuite.task.graph import registry
from visionsuite.task.registry import unavailable
falliti = registra_strumenti()
kind = sorted(registry())
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
riga = json.dumps(esito, ensure_ascii=False)
if sys.stdout is not None:
print(riga)
try:
f = cartella_dati() / "log" / "controllo-strumenti.json"
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
except OSError as e:
log.warning("controllo-strumenti.json non scritto: %s", e)
return 0 if kind and not falliti else 3
# ---------------------------------------------------------------- rete # ---------------------------------------------------------------- rete
def _porta_aperta(porta: int) -> bool: def _porta_aperta(porta: int) -> bool:
@@ -402,6 +455,8 @@ def main(argv=None) -> int:
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)") p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)") p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)") p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
p.add_argument("--controlla-strumenti", action="store_true",
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
args = p.parse_args(argv) args = p.parse_args(argv)
_prepara_log() _prepara_log()
@@ -415,6 +470,9 @@ def main(argv=None) -> int:
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre) messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
return 0 return 0
if args.controlla_strumenti:
return controlla_strumenti() # prima della licenza: la build non ha una licenza
if not controlla_licenza(args.senza_finestre): if not controlla_licenza(args.senza_finestre):
return 1 return 1
@@ -449,6 +507,7 @@ def main(argv=None) -> int:
_prepara_log(dati) # alembic ha riconfigurato il logging _prepara_log(dati) # alembic ha riconfigurato il logging
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio) log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
thread = avvia_server(args.host, dati) thread = avvia_server(args.host, dati)
if not args.senza_browser: if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env) # database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
+37 -2
View File
@@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba ⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False): non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
funziona ma e' piu' lento nel matching. funziona ma e' piu' lento nel matching.
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`): ⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo. si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
#> #>
@@ -85,8 +89,9 @@ try {
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))" $pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" } if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" }) $includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una # Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello. # pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
Write-Host "Pacchetti visionsuite: $($includi -join ' ')" Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..." # Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
@@ -103,6 +108,25 @@ if (Test-Path $leggimi) {
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH # Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';') $env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
$genera = @'
import sys
from importlib.metadata import entry_points
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
if not moduli:
sys.exit(1)
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
righe += [f" {m!r}," for m in moduli] + [")"]
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
print(*righe, sep=chr(10), file=f)
print(len(moduli))
'@
$nModuli = $genera | & $py - $strumenti
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
$out = Join-Path $radice 'build\nuitka' $out = Join-Path $radice 'build\nuitka'
New-Item -ItemType Directory -Force $out | Out-Null New-Item -ItemType Directory -Force $out | Out-Null
$nuitka = @( $nuitka = @(
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' } Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" } if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return } if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
$dist = Join-Path $radice 'dist' $dist = Join-Path $radice 'dist'
+76
View File
@@ -13,6 +13,8 @@ import importlib.util
import json import json
import os import os
import sqlite3 import sqlite3
import sys
import types
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
@@ -195,3 +197,77 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av
avvio.main(["--senza-finestre", "--senza-browser"]) avvio.main(["--senza-finestre", "--senza-browser"])
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1" assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
def _modulo_strumenti(monkeypatch, moduli):
# finto _strumenti.py generato da build.ps1
finto = types.ModuleType("_strumenti")
finto.MODULI = tuple(moduli)
monkeypatch.setitem(sys.modules, "_strumenti", finto)
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
from visionsuite.task.graph import registry
assert avvio.registra_strumenti() == []
assert len(registry()) > 0
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
visti = []
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
monkeypatch.setitem(sys.modules, "_strumenti", None)
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
def mai(*a, **k):
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
monkeypatch.setattr(avvio.lic, "verifica", mai)
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
monkeypatch.setattr(avvio, "avvia_server", mai)
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
dati = json.loads(capsys.readouterr().out)
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
assert dati["falliti"] == []
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
import visionsuite.task.graph as graph
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
monkeypatch.setattr(graph, "registry", lambda: {})
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
+16 -18
View File
@@ -12,12 +12,9 @@ class TestSelectRecipe:
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch): def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role.""" """Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch the class attribute instead of reload()ing config/blueprints.measure:
import config # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
import importlib monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [ mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"}, {"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -47,12 +44,9 @@ class TestSelectRecipe:
import re import re
from html.parser import HTMLParser from html.parser import HTMLParser
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch the class attribute instead of reload()ing config/blueprints.measure:
import config # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
import importlib monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [] mock_api_client.get_station_recipes.return_value = []
@@ -79,12 +73,9 @@ class TestSelectRecipe:
self, logged_in_client, mock_api_client, monkeypatch self, logged_in_client, mock_api_client, monkeypatch
): ):
"""#10: a lot number like ``L'23`` must not break the page.""" """#10: a lot number like ``L'23`` must not break the page."""
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch the class attribute instead of reload()ing config/blueprints.measure:
import config # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
import importlib monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [] mock_api_client.get_station_recipes.return_value = []
@@ -393,6 +384,13 @@ class TestStartSequence:
class TestTaskComplete: class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests.""" """GET /measure/complete/<recipe_id> tests."""
@pytest.fixture(autouse=True)
def _station(self, monkeypatch):
# The side_effect lists below include the /production-runs/current reply,
# which is only requested when a station is configured: pin it explicitly
# instead of relying on whatever an earlier test left behind (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_task_complete_renders(self, logged_in_client, mock_api_client): def test_task_complete_renders(self, logged_in_client, mock_api_client):
"""The summary carries the measurement clock, so it has to render with it.""" """The summary carries the measurement clock, so it has to render with it."""
mock_api_client.get.side_effect = [ mock_api_client.get.side_effect = [
@@ -6,6 +6,17 @@ a start button that will not start, a description that reads the way it was
written. written.
""" """
import pytest
@pytest.fixture(autouse=True)
def _no_station(monkeypatch):
# These tests assume no station is configured (e.g. _task_list feeds the mock a
# fixed number of replies, with no /production-runs/current among them); pin it
# so a STATION_CODE left behind by an earlier test cannot change the call count (#79).
# Tests that need a station set it themselves, after this fixture has run.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Point 11 - line breaks and bold, and nothing else # Point 11 - line breaks and bold, and nothing else
@@ -197,13 +208,9 @@ def test_avvia_is_untouched_when_nothing_is_required(
def test_selection_screen_carries_the_traceability_rules( def test_selection_screen_carries_the_traceability_rules(
logged_in_client, mock_api_client, monkeypatch, logged_in_client, mock_api_client, monkeypatch,
): ):
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch instead of reload()ing config/blueprints.measure: the patch is undone at
import importlib # teardown, a reload would leave ST-TEST behind for later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [ mock_api_client.get_station_recipes.return_value = [
{ {
+228
View File
@@ -0,0 +1,228 @@
"""Costruttore di ricette DXF compare dal campo (immagine + DXF + scala -> ricetta v2).
Logica pura, niente FastAPI ne' database: il worker (src/vision_worker/main.py)
lo espone come /recipes/dxf-compare, i test lo chiamano direttamente. Tutta la
visione sta in TieVision-Core (visionsuite.*). La calibrazione a scacchiera NON
sta qui: vive nell'acquisitore (scripts/calibrazione.py, #83).
Errori di input (pezzo non trovato, DXF illeggibile, parametri fuori range)
sono `CostruttoreError`: il worker li mappa a 422.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import tempfile
from pathlib import Path
import numpy as np
from visionsuite.core.image import Image
from src.vision.runner import RunOutcome, run_graph
class CostruttoreError(ValueError):
"""Input che non permette di costruire (422 per il worker), non un guasto."""
# Nomi/id fissi della ricetta (contratto #83 con backend e frontend).
DXF_FILE = "disegno.dxf"
CALIBRATION_FILE = "calibrazione.json"
_POLARITIES = ("dark", "light")
# Pose iniziali del disegno provate (gradi): il verso del pezzo non e' noto.
_OUTPUT_KEYS = ("dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max")
_START_ANGLES = (0.0, 90.0, 180.0, 270.0)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
"""Immagine -> grigio uint8 2D (LocateBlob accetta solo uint8)."""
px = np.asarray(pixels)
if px.ndim == 3:
px = px[..., 0] # multicanale: il primo, come fa LocateBlob
if px.dtype != np.uint8:
# float/uint16 -> scala sul massimo, come _to_gray_uint8 di vs-core
m = float(px.max()) if px.size else 1.0
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
return np.ascontiguousarray(px)
# ------------------------------------------------------------------ W2
def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, polarity: str,
reference_pose: dict, anchor: tuple[float, float, float] | None) -> dict:
"""Ricetta v2 fissa: LocateBlob "pezzo" + CompareDxf "dxf" agganciato a `align`.
`anchor` = (riga, colonna, angolo) dell'origine del disegno nell'immagine di
riferimento; None = solo il localizzatore (passo intermedio).
"""
tools: list[dict] = [{
"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
"values": {"polarity": polarity, "reference_pose": reference_pose},
"links": {},
}]
if anchor is not None:
row, col, angle = anchor
tools.append({
"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
"values": {
"dxf": "disegno",
"anchor": "align",
"anchor_offset_row_px": float(row),
"anchor_offset_col_px": float(col),
"offset_angle_deg": float(angle),
"band_plus": float(band_mm),
"band_minus": float(band_mm),
"nominal_unit": "mm",
# tolleranza di points_in_pct: nominale = limite, plus = 100 - limite
"points_in_pct_nominal": float(min_points_pct),
"points_in_pct_plus": float(100.0 - min_points_pct),
"points_in_pct_minus": 0.0,
},
"links": {"align": {"tool": "pezzo", "property": "poses"}},
})
return {
"schema_version": 2,
"name": "Confronto DXF",
# sha256 obbligatorio per vs-task (verifica i file della cartella ricetta)
"meta": {"dxf": {"disegno": {"path": DXF_FILE, "sha256": dxf_sha}},
"calibration": {"path": CALIBRATION_FILE, "sha256": cal_sha}},
"tools": tools,
}
def _locate_reference(gray: np.ndarray, polarity: str) -> dict:
"""Posa del pezzo sull'immagine di riferimento (LocateBlob, soglia Otsu)."""
from visionsuite.task import Task, load_tools
load_tools()
graph = _graph('', '', 1.0, 95.0, polarity, {"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
task = Task.from_dict(graph)
task.run(context={"image": Image(gray)})
pezzo = task["Pezzo"]
if not pezzo.found_poses:
raise CostruttoreError(
"pezzo non trovato nell'immagine di riferimento "
f"(polarity={polarity}): controllare luce, sfondo e polarita'"
)
return pezzo.found_poses[0].to_dict()
def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> list[tuple[float, float, float]]:
"""Pose (riga, col, angolo) del disegno sull'immagine: ICP da 4 orientamenti.
Il disegno e' in px (origine DXF, y capovolta); si parte da ognuno dei 4
angoli con i centroidi sovrapposti e l'ICP rifinisce. Ritorna le posa trovate
(anche per orientamenti che non convergono: le giudica la ricetta eseguita).
"""
from visionsuite.calibration import ScaleCalibration
from visionsuite.dxf.compare import DXFCompare, _stack
from visionsuite.task.pose import Pose
cmp_ = DXFCompare(dxf_path, ScaleCalibration(px_per_mm=px_per_mm))
drawing = _stack(cmp_.drawing_contours())
measured = _stack(cmp_.measured_contours(Image(gray)))
if len(drawing) < 3:
raise CostruttoreError("DXF senza geometria utilizzabile")
if len(measured) < 3:
raise CostruttoreError("pezzo non trovato: nessun bordo nell'immagine")
dc, mc = drawing.mean(axis=0), measured.mean(axis=0)
anchors = []
for theta in _START_ANGLES:
# traslazione che porta il centroide del disegno ruotato sul misurato
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
try:
result = cmp_.compare(Image(gray), initial_pose=start)
except Exception: # noqa: BLE001 - un orientamento che cade non ferma gli altri
continue
# transform: misurato -> disegno; la posa del disegno e' l'inversa
m = np.linalg.inv(result.transform)
angle = math.degrees(math.atan2(m[1, 0], m[0, 0]))
anchors.append((float(m[0, 2]), float(m[1, 2]), angle))
return anchors
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
def build_dxf_compare(
gray,
dxf_bytes: bytes,
px_per_mm: float,
band_mm: float = 0.1,
min_points_pct: float = 95.0,
polarity: str = "dark",
) -> dict:
"""Ricetta DXF compare dall'immagine di riferimento + disegno + scala.
Ritorna {graph, files, outputs, overlay, outcome}. `files` sono i due file
della cartella ricetta (b64): disegno.dxf e calibrazione.json.
"""
if not (px_per_mm > 0 and math.isfinite(px_per_mm)):
raise CostruttoreError("px_per_mm deve essere > 0")
if not (band_mm > 0 and math.isfinite(band_mm)):
raise CostruttoreError("band_mm deve essere > 0")
if not (0 < min_points_pct <= 100):
raise CostruttoreError("min_points_pct deve stare in (0, 100]")
if polarity not in _POLARITIES:
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes:
raise CostruttoreError("DXF vuoto")
frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
dxf_sha = hashlib.sha256(dxf_bytes).hexdigest()
cal_sha = hashlib.sha256(calibration_bytes).hexdigest()
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
dxf_path = folder / DXF_FILE
dxf_path.write_bytes(dxf_bytes)
(folder / CALIBRATION_FILE).write_bytes(calibration_bytes)
# DXF leggibile? (ezdxf solleva su file corrotto/non DXF)
from visionsuite.io.dxf import read_xld_dxf
try:
drawing = list(read_xld_dxf(str(dxf_path), sampling_distance=0.1,
scale=px_per_mm, flip_y=True))
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
if not drawing:
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
reference_pose = _locate_reference(frame, polarity)
anchors = _candidate_anchors(frame, dxf_path, px_per_mm)
if not anchors:
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
# Verita' = eseguire davvero la ricetta sul riferimento: vince il maggior points_in_pct
best: tuple[float, dict, RunOutcome] | None = None
for anchor in anchors:
graph = _graph(dxf_sha, cal_sha, band_mm, min_points_pct, polarity, reference_pose, anchor)
outcome = _run(frame, graph, px_per_mm, folder)
pct = outcome.outputs.get("dxf.points_in_pct")
if pct is not None and (best is None or pct > best[0]):
best = (pct, graph, outcome)
if best is None:
raise CostruttoreError(
"disegno non sovrapponibile al pezzo: il confronto non ha prodotto punti"
)
_, graph, outcome = best
# solo le quattro uscite del contratto (n_points resta fuori)
outputs = {k: v for k, v in outcome.outputs.items() if k in _OUTPUT_KEYS}
return {
"graph": graph,
"files": {DXF_FILE: _b64(dxf_bytes), CALIBRATION_FILE: _b64(calibration_bytes)},
"outputs": outputs,
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
+114
View File
@@ -0,0 +1,114 @@
"""Costruttore di ricette dal campo: immagine + DXF + scala -> ricetta v2."""
import pytest
# Come test_runner.py: l'albero vision richiede Python >=3.13; su 3.11 e' skipped.
pytest.importorskip(
"visionsuite.task",
reason=(
"the vision tree requires Python >=3.13; run it with "
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
),
)
import base64
import json
from pathlib import Path
import cv2
import numpy as np
from src.vision.costruttori import (
CALIBRATION_FILE,
DXF_FILE,
CostruttoreError,
build_dxf_compare,
)
from src.vision.runner import run_graph
SAMPLE_DXF = (
Path(__file__).resolve().parents[3]
/ "vendor/visionsuite/packages/vs-dxf/tests/fixtures/sample.dxf"
)
DXF_WIDTH_MM, DXF_HEIGHT_MM, DXF_HOLE_R_MM = 200.0, 100.0, 10.0
PX_PER_MM = 4.0
def a_scene(angle_deg: float = 0.0, shift_px: tuple[float, float] = (0.0, 0.0),
margin_mm: float = 60.0) -> np.ndarray:
"""Il pezzo del sample.dxf (scuro su bianco) a 4 px/mm, ruotato e traslato."""
w = int((DXF_WIDTH_MM + 2 * margin_mm) * PX_PER_MM)
h = int((DXF_HEIGHT_MM + 2 * margin_mm) * PX_PER_MM)
img = np.full((h, w), 255, np.uint8)
cx, cy = w // 2, h // 2
hw, hh = int(DXF_WIDTH_MM * PX_PER_MM / 2), int(DXF_HEIGHT_MM * PX_PER_MM / 2)
cv2.rectangle(img, (cx - hw, cy - hh), (cx + hw, cy + hh), 0, -1)
cv2.circle(img, (cx, cy), int(DXF_HOLE_R_MM * PX_PER_MM), 255, -1)
if angle_deg or any(shift_px):
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # >0 = antiorario a video
m[0, 2] += shift_px[0]
m[1, 2] += shift_px[1]
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
return img
# ------------------------------------------------------------------ ricetta DXF
def test_ricetta_dxf_gira_su_riferimento_e_su_scena_ruotata(tmp_path):
dxf = SAMPLE_DXF.read_bytes()
risultato = build_dxf_compare(a_scene(0.0), dxf, px_per_mm=PX_PER_MM, band_mm=2.0)
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0 # sul riferimento
assert set(risultato["outputs"]) == {
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
assert set(risultato["files"]) == {DXF_FILE, CALIBRATION_FILE}
assert json.loads(base64.b64decode(risultato["files"][CALIBRATION_FILE])) == {
"px_per_mm": PX_PER_MM}
assert risultato["overlay"] and risultato["outcome"] is not None
graph = risultato["graph"]
assert graph["schema_version"] == 2
# path come da contratto; lo sha256 lo esige vs-task per ogni file in meta
assert graph["meta"]["dxf"]["disegno"]["path"] == "disegno.dxf"
assert graph["meta"]["calibration"]["path"] == "calibrazione.json"
assert len(graph["meta"]["dxf"]["disegno"]["sha256"]) == 64
pezzo, dxf_tool = graph["tools"]
assert (pezzo["id"], pezzo["kind"], pezzo["name"]) == ("pezzo", "LocateBlob", "Pezzo")
assert (dxf_tool["id"], dxf_tool["kind"], dxf_tool["name"]) == ("dxf", "CompareDxf", "Confronto DXF")
assert dxf_tool["links"] == {"align": {"tool": "pezzo", "property": "poses"}}
# La cartella ricetta come la scriverebbe il backend, poi la scena ruotata di 5 gradi
for nome, b64 in risultato["files"].items():
(tmp_path / nome).write_bytes(base64.b64decode(b64))
ruotata = a_scene(5.0, shift_px=(30.0, 20.0))
esito = run_graph(ruotata, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
assert not esito.failures, esito.failures
assert esito.outputs["dxf.points_in_pct"] > 95.0
def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
# il riferimento non e' a 0 gradi: la ricetta si costruisce lo stesso
risultato = build_dxf_compare(a_scene(20.0, shift_px=(-15.0, 10.0)),
SAMPLE_DXF.read_bytes(), PX_PER_MM, band_mm=2.0)
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
def test_immagine_senza_pezzo_solleva():
bianca = np.full((600, 800), 255, np.uint8)
with pytest.raises(CostruttoreError, match="pezzo"):
build_dxf_compare(bianca, SAMPLE_DXF.read_bytes(), PX_PER_MM)
def test_dxf_illeggibile_solleva():
with pytest.raises(CostruttoreError, match="DXF"):
build_dxf_compare(a_scene(), b"questo non e' un dxf", PX_PER_MM)
@pytest.mark.parametrize("kwargs", [
{"px_per_mm": 0.0}, {"px_per_mm": PX_PER_MM, "band_mm": 0.0},
{"px_per_mm": PX_PER_MM, "min_points_pct": 0.0},
{"px_per_mm": PX_PER_MM, "min_points_pct": 101.0},
{"px_per_mm": PX_PER_MM, "polarity": "grigio"},
])
def test_parametri_ricetta_invalidi(kwargs):
with pytest.raises(CostruttoreError):
build_dxf_compare(a_scene(), SAMPLE_DXF.read_bytes(), **kwargs)
+65
View File
@@ -17,6 +17,7 @@ import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
from src.vision.runner import ( from src.vision.runner import (
GraphBuildError, GraphBuildError,
RunOutcome, RunOutcome,
@@ -223,3 +224,67 @@ async def validate(
validate_graph, parsed_graph, resolved_dir validate_graph, parsed_graph, resolved_dir
) )
return {"ok": ok, "errors": errors, "outputs": outputs} return {"ok": ok, "errors": errors, "outputs": outputs}
def _decode_and_build_dxf(content: bytes, dxf: bytes, px_per_mm: float,
band_mm: float, min_points_pct: float,
polarity: str) -> dict:
"""#83: meta' sincrona di /recipes/dxf-compare (decodifica + costruzione),
in un thread come `_decode_and_run`."""
try:
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return build_dxf_compare(frame, dxf, px_per_mm, band_mm, min_points_pct, polarity)
@app.post("/recipes/dxf-compare")
async def recipes_dxf_compare(
image: UploadFile = File(...),
dxf: UploadFile = File(...),
px_per_mm: float = Form(...),
band_mm: float = Form(0.1),
min_points_pct: float = Form(95.0),
polarity: str = Form("dark"),
) -> dict:
"""#83: costruisce una ricetta DXF compare dall'immagine di riferimento, il
disegno e la scala. Stessi semaforo, timeout e mappa errori di /run."""
content = await image.read()
dxf_bytes = await dxf.read()
if _run_slots.locked():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"worker busy: already running {_MAX_CONCURRENCY} job(s)",
)
async with _run_slots:
try:
return await asyncio.wait_for(
asyncio.to_thread(
_decode_and_build_dxf, content, dxf_bytes, px_per_mm,
band_mm, min_points_pct, polarity,
),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"vision run exceeded {_RUN_TIMEOUT_S}s",
) from exc
except _ImageDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"image is not decodable: {exc}",
) from exc
except (CostruttoreError, GraphBuildError) as exc:
# pezzo non trovato, DXF illeggibile, parametri invalidi: input, non guasto
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
) from exc
@@ -0,0 +1,82 @@
"""POST /recipes/dxf-compare del worker (#83): forma della risposta ed errori."""
import pytest
# Come test_worker.py: serve l'albero vision (Python >=3.13), altrimenti skipped.
pytest.importorskip(
"visionsuite.task",
reason="the vision tree requires Python >=3.13; see test_worker.py",
)
import base64
import io
import time
import numpy as np
from httpx import ASGITransport, AsyncClient
from PIL import Image
from src.vision.tests.test_costruttori import PX_PER_MM, SAMPLE_DXF, a_scene
from src.vision_worker.main import app
def _png(array: np.ndarray) -> bytes:
buffer = io.BytesIO()
Image.fromarray(array).save(buffer, format="PNG")
return buffer.getvalue()
async def _post(image: bytes, dxf: bytes, **data):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(
"/recipes/dxf-compare",
files={"image": ("riferimento.png", image, "image/png"),
"dxf": ("disegno.dxf", dxf, "application/octet-stream")},
data={"px_per_mm": str(PX_PER_MM), "band_mm": "2.0", **data},
)
@pytest.mark.asyncio
async def test_dxf_compare_200_con_le_chiavi_del_contratto():
inizio = time.monotonic()
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes())
durata = time.monotonic() - inizio
assert response.status_code == 200, response.text
body = response.json()
assert {"graph", "files", "outputs", "overlay", "outcome"} <= set(body)
assert set(body["files"]) == {"disegno.dxf", "calibrazione.json"}
assert base64.b64decode(body["files"]["disegno.dxf"]) == SAMPLE_DXF.read_bytes()
assert set(body["outputs"]) == {
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
assert body["outputs"]["dxf.points_in_pct"] > 95.0
assert body["graph"]["schema_version"] == 2
print(f"\nW2 sulla scena di prova: {durata:.2f}s") # riportato nel rapporto
@pytest.mark.asyncio
async def test_dxf_compare_immagine_senza_pezzo_e_422():
bianca = np.full((600, 800), 255, np.uint8)
response = await _post(_png(bianca), SAMPLE_DXF.read_bytes())
assert response.status_code == 422
assert "pezzo" in response.json()["detail"]
@pytest.mark.asyncio
async def test_dxf_compare_dxf_illeggibile_e_422():
response = await _post(_png(a_scene(0.0)), b"non e' un dxf")
assert response.status_code == 422
assert "DXF" in response.json()["detail"]
@pytest.mark.asyncio
async def test_dxf_compare_immagine_non_decodificabile_e_422():
response = await _post(b"non e' un'immagine", SAMPLE_DXF.read_bytes())
assert response.status_code == 422
assert "not decodable" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_dxf_compare_parametro_fuori_range_e_422():
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
assert response.status_code == 422