22 Commits

Author SHA1 Message Date
Adriano 39a6f62089 fix(test): prima parte vuol dire tracciato, non fuori da vendor
Il controllo sui riferimenti esterni scandiva anche i file che git non
conosce. Una copia di comodo di una libreria lasciata in static/js/ --
fabric-debug.js -- sembrava codice nostro e faceva fallire la prova con gli
URL nei propri commenti, ma non sta nel repository e in reparto non ci
arriva mai. Su un checkout pulito la prova passava: falliva solo su chi
aveva quel file, cioe' proprio su chi stava lavorando.

Il filtro ora e' la tracciabilita', non il percorso. Non i pattern di
.gitignore, che non dicono nulla su un file gia' tracciato. Se git non
risponde si scandisce tutto come prima: una prova-guardia che ammutolisce
quando perde l'appoggio e' peggio di una che grida al lupo.

Verificato che morda ancora: un fetch verso un CDN in un file tracciato la
fa fallire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-17 11:02:40 +02:00
Adriano f20b92bafb test(vision): la suite non lascia piu' immagini nel repository
Il file di test dei limiti di upload scriveva un jpeg vero in
uploads/vision/reference/ a ogni esecuzione: gli mancava la fixture di
dirottamento che test_vision_reference.py ha gia'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 20:28:35 +02:00
Adriano 6bfe3a5d1a docs(spec): registra le correzioni imposte dall'esecuzione
Il gate del fuori tolleranza vale per l'acquisizione e non per la quota:
la regola era pensata per la misura sequenziale e su un evento simultaneo
produceva un blocco permanente.

Piu' il bi-ambiente Python, la versione del motore timbrata al build, la
motivazione della divisione degli extra corretta, e due cose promesse e non
mantenute -- expected_json e image_path -- dichiarate come consegne del
piano 1b invece che lasciate come intenzioni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 20:00:10 +02:00
Adriano 5717b3e7aa docs(readme): aggiorna il conteggio dei test, con l'ambiente in cui vale
Il README dichiarava ancora 360 pass, 0 fail - numero superato dal
lavoro su questo branch. Aggiornato a 390 pass, 1 fail, 4 skip, con
l'ambiente in cui vale (Python 3.11, uv run pytest, senza l'extra
vision) esplicitato accanto: un conteggio senza il suo ambiente non
dice nulla, regola che il progetto applica già alla sezione del worker
di visione poco sopra. Il fallimento riportato è preesistente e
indipendente da questo lavoro (test_offline.py, copia locale di
Fabric.js non tracciata da git).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:49:21 +02:00
Adriano 85d7976f4e docs(vision): commenta perché camera resta fuori dal pattern client
MeasurementCreate.input_method accetta solo usb_caliper|manual anche se
l'enum ORM e il database permettono ormai camera. È voluto: una misura
da camera deve nascere in vision_service.execute_task, mai da un client
che posta JSON direttamente - non c'è dietro né immagine, né grafo, né
engine_version. Commento aggiunto perché non venga "corretto" tornando
alla lettera dell'enum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:49:21 +02:00
Adriano a72e3f11af test(vision): chiude il punto cieco del confine, "Acquire" era un fantasma
FORBIDDEN confrontava solo il primo segmento del path importato: "from
src.vision.runner import ..." dava radice "src", mai vietata (il
backend importa se stesso di continuo), quindi il test non l'avrebbe
mai vista. Confermato manualmente: la logica vecchia su quell'import
restituisce un insieme vuoto di violazioni. Ora si confrontano i path
puntati per intero contro "src.vision"/"src.vision_worker" come
prefissi, non solo la prima radice.

"Acquire" in FORBIDDEN era un nome di classe/SDK (Balluff "mvIMPACT
Acquire"), mai una radice di import: non poteva mai far scattare nulla.
vs-camera importa sotto lo stesso namespace visionsuite di vs-core
(vendor/visionsuite/packages/vs-camera/pyproject.toml: "il codice si
importa come visionsuite.camera...."), già coperto da "visionsuite":
non c'era una radice separata da aggiungere, quindi è stato tolto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:45:00 +02:00
Adriano 46566ebfa5 fix(vision): build fallisce senza VISION_ENGINE_VERSION, healthcheck vero
Un build senza la variabile produceva un'immagine che partiva e
rispondeva "healthy" pur fallendo ogni richiesta, /health incluso: non
c'era un healthcheck a dirlo. Tre correzioni: RUN test -n
"$VISION_ENGINE_VERSION" in Dockerfile.vision dopo l'ARG, fallisce
subito con il comando da lanciare; healthcheck su /health nel servizio
vision di entrambi i compose; VISION_ENGINE_VERSION documentata in
.env.example accanto a VISION_WORKER_URL, che già c'era.

Verificato con docker build reale: senza la variabile fallisce al passo
del test con il messaggio atteso; con la variabile impostata l'immagine
si costruisce, il container parte, e python3 -c
"urllib.request.urlopen('http://localhost:8100/health')" - lo stesso
comando usato nell'healthcheck - risponde 200 con l'engine_version
giusta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:43:20 +02:00
Adriano 0bd95367bb fix(vision): limite di tipo e dimensione sui tre endpoint di upload
/execute, /preview e /reference-images leggevano l'immagine senza
limite di tipo o dimensione, e /reference-images scriveva su disco con
estensione .png fissa qualunque fosse il tipo reale. Riusa la
convenzione già esistente in files.py (ALLOWED_IMAGE_TYPES,
validate_file_size) invece di inventarne una seconda: tipo controllato
dall'header prima di leggere, dimensione controllata sul corpo letto,
un PDF non è qualcosa che un grafo di visione può misurare.
save_reference_image ora scrive con l'estensione del tipo reale
dell'upload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:41:14 +02:00
Adriano 38ef0587f5 fix(vision): il guasto del worker resta suo, il gate anticipa il worker
I2: _call_worker lasciava propagare httpx.ConnectError e ReadTimeout
(worker fermo o job oltre i 120s), senza handler in main.py: l'operatore
vedeva un 500 nudo. Ora ConnectError->502, ReadTimeout->504, ciascuno con
un messaggio che nomina il guasto. La forma della risposta è validata
alla frontiera: outputs/failures/engine_version mancanti o un body non-
dict non fanno più KeyError, rispondono 502 "malformed".

M3: il gate del fuori tolleranza (pending_authorisation) girava dopo
_call_worker: un operatore bloccato bruciava un'intera esecuzione di
visione prima di ricevere il 409. Spostato prima della chiamata al
worker; le quote necessarie erano già caricate prima, nessun'altra query
serviva.

Copertura: test_vision_worker_transport.py (nuovo) per i quattro casi di
guasto/malformazione; test_vision_execute.py aggiunge
test_a_pending_fail_blocks_before_the_worker_is_called, rossa prima dello
spostamento del gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:38:56 +02:00
Adriano 533edc5be8 fix(vision): il grafo di visione sopravvive a copy-on-write e creazione
_copy_tasks_to_version copiava una lista esplicita di campi che ometteva
vision_json e vision_output: la prima modifica a una ricetta con misure
produceva una versione nuova con i task camera senza grafo. Stessa
famiglia di bug nei percorsi di creazione (POST /api/recipes/{id}/tasks,
POST /api/tasks/{id}/subtasks): i campi erano dichiarati negli schemi ma
mai passati ai costruttori ORM, quindi l'API rispondeva 200 senza
salvare nulla. TDD: test_vision_copy_on_write.py fallisce prima del fix,
passa dopo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:36:18 +02:00
Adriano 3870cc3b0f feat(vision): immagini di riferimento, per comporre senza camera
VisionReferenceImage (migration 013, chained after 012_vision_results):
task_id, path, station_id, device_code, calibration_snapshot,
engine_version, note, expected_json, acquired_at. station_id/device_code/
calibration_snapshot/engine_version/expected_json stay nullable - nessun
endpoint di questo task li popola ancora, arrivano coi Piani 1b/2/4.

save_reference_image salva sotto settings.upload_path (non upload_dir,
come production_export_service). preview esegue il grafo e non salva
niente - comporre non e' misurare, lo asserisce il test.

POST /api/vision/reference-images e /api/vision/preview su require_maker
(non require_measurement_tec di execute): comporre e provare sono lavoro
del Maker, non misure.

Suite intera su Python 3.11.15 locale (uv run pytest -q, SQLite
in-memory per il backend): 1 failed, 373 passed, 4 skipped in 82.35s.
Il fallimento e' quello preesistente e non correlato di
test_no_first_party_script_calls_out (fabric-debug.js locale,
git-ignored) - 371 passed prima di questo task, +2 qui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 19:11:47 +02:00
Adriano 398af016f8 test(vision): il confine fra server e VisionSuite è sorvegliato 2026-08-16 19:03:16 +02:00
Adriano b86eeada86 fix(vision): il gate del fuori tolleranza vale per l'acquisizione, non per la quota
Un'acquisizione camera produce N quote nello stesso istante: non c'e "andare
avanti" fra l'una e l'altra, quindi il gate sequenziale di save_measurement
non deve scattare dentro il lotto. execute_task valuta pending_authorisation
una sola volta per l'intera acquisizione (bloccando solo se il fail pendente
non appartiene alle quote di questo task) e passa enforce_tolerance_gate=False
a ogni save_measurement del lotto. Query delle quote ordinata per
marker_number, cosi l'esito non dipende dall'ordine di ritorno di SQLite/MySQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:58:46 +02:00
Adriano ddf7788d77 feat(vision): il server esegue tramite worker e salva misure vere
Aggiunge POST /api/vision/execute: route l'immagine al worker di visione,
mappa le uscite del grafo sulle quote e le salva con save_measurement -
stesso verdetto, stesso gate del fuori tolleranza di ogni altra misura.
Nuova tabella vision_results (una riga per acquisizione, non per quota) e
input_method 'camera' su measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:40:12 +02:00
Adriano 05f2937611 fix(vision): dichiara le dipendenze del worker, pin numerico HTTP vs runner
Ruling R9: separa l'extra vision (runner: 4 pacchetti VisionSuite + numpy)
da vision-worker (fastapi, uvicorn, pillow, python-multipart, sopra vision).
Prima il worker risolveva solo perche' vs-pm2d le lista per conto suo; un
domani lo stub di stazione dovra' incorporare il runner senza trascinarsi
dietro un server web che non gli serve.

Aggiorna Dockerfile.vision e README.md al nuovo extra; il test del worker ora
pinna anche il valore numerico del diametro (non solo la chiave), agganciato
alla stessa tolleranza del test in-process del runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:28:06 +02:00
Adriano 71da162e1f feat(vision): il worker che espone il runner, versione stampata al build
Container FastAPI separato (Dockerfile.vision, python:3.13-slim) che
espone run_graph/engine_version del Task 2 via POST /run e GET /health,
cosi' l'immagine del server principale non importa mai VisionSuite.

engine_version() ora legge VISION_ENGINE_VERSION se impostata, altrimenti
ricade su git rev-parse nel checkout di sviluppo, e non inventa mai un
valore: senza nessuna delle due solleva un errore esplicito. Nel container
il fallback a git non puo' funzionare (.git del submodule punta fuori dal
build context), quindi Dockerfile.vision prende il commit come build arg
e lo fissa in ambiente; i compose file lo passano da VISION_ENGINE_VERSION.

Nessuna porta pubblicata e nessuna label Traefik sul servizio vision: e'
raggiungibile solo dal server, su tmflow-net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:12:04 +02:00
Adriano 9387e7c306 fix(vision): traduce in inglese docstring e commenti di test_runner.py
R7: i commenti trascritti verbatim dal brief erano in italiano, contro il
vincolo globale "comments in English". Solo prosa toccata - nomi dei test,
assert, valori, struttura invariati. runner.py verificato: gia' in inglese,
nessuna modifica.
2026-08-16 17:52:56 +02:00
Adriano 0a3d1f5222 fix(vision): aggancia i test alla suite di default, skip pulito su 3.11
testpaths include src/vision/tests: prima uv run pytest (365 test) non ci
passava mai. Niente conftest.py: un pytest.importorskip a livello di modulo
in conftest.py rompe l'intera sessione, perche' Skipped eredita da
BaseException e _importconftest cattura solo Exception (verificato con
traceback, provato sia su src/vision/tests sia su src/vision come radice).
La guardia sta invece in cima a test_runner.py, prima di numpy (assente
anch'esso su 3.11) - idioma standard di pytest, cattura pulita a livello di
collection. README aggiornato con il comando reale per farli girare.
2026-08-16 17:39:57 +02:00
Adriano c5f3366dd1 feat(vision): il runner, unica implementazione dell'esecuzione
Aggiunge il sottomodulo vendor/visionsuite (pin f095fcc), src/vision/runner.py
(run_graph/engine_version, pura libreria: niente HTTP, niente DB) e i test.

Deviazione dal brief: vs-task/vs-measure/vs-pm2d dichiarano
requires-python>=3.13 a monte, mentre il monorepo resta a 3.11. L'extra
`vision` porta marker `python_version>='3.13'` cosi' la sync di base
(server+client+dev, 3.11) resta intatta; per lavorare sulla vision serve
`uv sync --extra vision --extra dev --python 3.13`. Ricade sul Task 3: il
worker (Dockerfile.vision) non puo' partire da python:3.11-slim come previsto
dal suo brief, deve usare 3.13.
2026-08-16 17:28:09 +02:00
Adriano 9546ded1e8 fix(vision): allinea la label del task_type al valore corretto dxf_compare 2026-08-16 17:13:26 +02:00
Adriano b578ac9e1b feat(vision): il grafo vive sul task, la quota nomina la sua uscita 2026-08-16 17:10:20 +02:00
Adriano cb0fab8c92 chore: ignora lo scratch di subagent-driven-development
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 17:01:43 +02:00
45 changed files with 2830 additions and 19 deletions
+9
View File
@@ -30,6 +30,15 @@ STATION_CODE=ST-DEFAULT
# another station's recipes would silently break traceability.
STATION_SWITCH_ENABLED=0
# --- Vision ---
# Internal address of the vision worker. Never exposed outside tmflow-net.
VISION_WORKER_URL=http://vision:8100
# The pinned VisionSuite commit, stamped into the vision image at build time
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
# immediately without it. Set with:
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
VISION_ENGINE_VERSION=
# --- File Storage ---
# Resolved against the project root in src/backend/config.py.
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
+3
View File
@@ -77,3 +77,6 @@ nul
# Competitor analysis (local only)
Concorrente/
docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/visionsuite"]
path = vendor/visionsuite
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
+41
View File
@@ -0,0 +1,41 @@
FROM python:3.13-slim
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
# needs libGL and glib at runtime even though it is never imported by name here.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY vendor/ ./vendor/
RUN uv sync --extra vision-worker --frozen --no-dev
COPY src/vision/ ./src/vision/
COPY src/vision_worker/ ./src/vision_worker/
# Ruling R8: the version is stamped at build time, not discovered at runtime.
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
# outside the build context - so `git rev-parse HEAD` cannot work in here.
# Pass the submodule's commit in from the host, which does have git:
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
# docker compose build vision
ARG VISION_ENGINE_VERSION
# I4: a build without the commit must fail now, at build time - not later, at
# every request including /health, with a container Docker still reports as
# healthy because no healthcheck existed to say otherwise.
RUN test -n "$VISION_ENGINE_VERSION" || { \
echo "VISION_ENGINE_VERSION is required. Build with:"; \
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
exit 1; \
}
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
EXPOSE 8100
# Two workers, not four: each holds the vision stack in memory.
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
+84 -1
View File
@@ -386,7 +386,89 @@ uv run pytest --cov src/backend
uv run pytest src/frontend/flask_app/tests/
```
Stato corrente su `V3.0.0`: **360 pass, 0 fail** (212 backend + 148 frontend).
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
Per eseguirli davvero:
```bash
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
uv sync --extra vision --extra dev --python 3.13
# Esegue i test del runner di visione in quell'ambiente
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
```
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
consumatore che incorpora solo il runner (come un futuro agente di stazione,
che non è un servizio web) non deve trascinarsi dietro un server che non gli
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
per avere un worker funzionante:
```bash
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
```
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
gli extra:
```bash
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
```
### Il worker di visione (`Dockerfile.vision`)
Il worker gira in un container separato dal server FastAPI principale, così
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
richieda di riavviare il traffico di produzione. Il container non pubblica
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
`http://vision:8100` sulla rete `tmflow-net`.
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
(`engine_version`), perché una stazione che misura con un motore diverso da
quello atteso deve poter essere identificata. Il container, però, non ha
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
submodule, e la copia `.git` che lo collega al repository ospitante non
viene inclusa nel contesto di build — quindi la versione non può essere
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
dove il repository Git è presente per intero:
```bash
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
docker compose -f docker-compose.dev.yml build vision
```
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
ricade su `git rev-parse` se la variabile non è impostata — comodo per
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
il `.git` del submodule non arriva nel contesto di build, e il worker
risponde con un errore esplicito su `/health` invece di indovinare una
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
in produzione).
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
che nei propri commenti cita pagine di documentazione esterne.
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
proprietà che sopravvivono solo se qualcuno le controlla:
@@ -413,6 +495,7 @@ Copia `.env.example` in `.env` e configura:
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
+18
View File
@@ -33,6 +33,7 @@ services:
environment:
DB_HOST: mysql
UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
volumes:
- upload_data:/app/uploads
depends_on:
@@ -41,6 +42,23 @@ services:
networks:
- tmflow-net
vision:
build:
context: .
dockerfile: Dockerfile.vision
args:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- tmflow-net
client:
build:
context: .
+18
View File
@@ -33,6 +33,7 @@ services:
environment:
DB_HOST: mysql
UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
volumes:
- upload_data:/app/uploads
depends_on:
@@ -50,6 +51,23 @@ services:
- tmflow-net
- traefik-net
vision:
build:
context: .
dockerfile: Dockerfile.vision
args:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- tmflow-net
client:
build:
context: .
@@ -159,6 +159,14 @@ Sblocca tre cose che altrimenti non ci sarebbero:
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
sottomodulo, con un motore che sta su due host.
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
> completa.
## Architettura
### I componenti
@@ -379,6 +387,73 @@ richiede una camera collegata.
e i driver possono stare sulla macchina o nel container. La decisione è
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
## Correzioni imposte dall'esecuzione
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
che qui erano scritte male o taciute, corrette dai fatti.
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
lettura cattiva irrisolta.
Una camera produce tutte le N quote **nello stesso istante**. Dentro
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
### Il repository è deliberatamente bi-ambiente
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
tengono separate le due risoluzioni.
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
### La versione del motore si timbra al build
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
nel contesto di build non esiste: interrogare git dentro il container non
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
l'altro. Il build fallisce se la variabile manca.
### La divisione degli extra compra esplicitezza, non peso
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
— e va aggiunta alle verifiche su VisionSuite qui sotto.
### L'immagine di produzione non viene ancora conservata
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
### Prima di applicare le migrazioni in produzione
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
reali, perché `measurements` è la tabella grossa.
## Da verificare prima di cominciare
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
+33 -1
View File
@@ -53,6 +53,32 @@ dev = [
"coverage>=7.0.0",
]
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
# marker keeps the base 3.11 resolution untouched. numpy is declared
# explicitly - runner.py imports it directly, and it must not depend on
# being pulled in as a side effect of vs-pm2d's own dependencies.
vision = [
"visionsuite; python_version>='3.13'",
"vs-task; python_version>='3.13'",
"vs-measure; python_version>='3.13'",
"vs-pm2d; python_version>='3.13'",
"numpy; python_version>='3.13'",
]
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
# whole structure exists so a consumer installs only what it needs, and a
# later station agent embeds the same runner without being a web service.
# It must not drag fastapi/uvicorn onto shop-floor PCs.
vision-worker = [
"tiemeasureflow[vision]",
"fastapi>=0.110.0; python_version>='3.13'",
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
"pillow>=10.0.0; python_version>='3.13'",
"python-multipart>=0.0.6; python_version>='3.13'",
]
[project.scripts]
# Backend
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
@@ -70,6 +96,12 @@ packages = ["src/backend", "src/frontend"]
# Pin the resolver to the deps we declared; reproducible builds.
package = false
[tool.uv.sources]
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 }
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
+3
View File
@@ -179,6 +179,7 @@ async def create_task(
file_path=data.file_path,
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
)
db.add(new_task)
await db.flush()
@@ -197,6 +198,7 @@ async def create_task(
ltl=sub_data.ltl,
unit=sub_data.unit,
image_path=sub_data.image_path,
vision_output=sub_data.vision_output,
)
db.add(sub)
@@ -340,6 +342,7 @@ async def create_subtask(
ltl=data.ltl,
unit=data.unit,
image_path=data.image_path,
vision_output=data.vision_output,
)
db.add(subtask)
await db.flush()
+133
View File
@@ -0,0 +1,133 @@
"""The client asks for a measurement and gets an outcome.
It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
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.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
# A vision graph measures pixels, not a PDF: stricter than files.py's
# ALLOWED_TYPES, which also accepts documents for technical drawings.
_IMAGE_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
"""Shared by the endpoints of this router."""
task = (await db.execute(
select(RecipeTask).where(RecipeTask.id == task_id)
)).scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return task
async def _read_image(image: UploadFile) -> bytes:
"""Type checked from the header before reading, size checked once the body
is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here.
"""
if image.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.",
)
content = await image.read()
if not validate_file_size(len(content)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"File size {len(content)} bytes exceeds maximum "
f"{settings.max_upload_size_mb}MB"
),
)
return content
@router.post("/execute", response_model=VisionExecuteResponse)
async def execute(
task_id: int = Form(...),
image: UploadFile = File(...),
lot_number: str | None = Form(None),
serial_number: str | None = Form(None),
production_run_id: int | None = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionExecuteResponse:
task = await _task_or_404(db, task_id)
result, saved = await vision_service.execute_task(
db,
task,
await _read_image(image),
current_user.id,
version_id=task.version_id,
lot_number=lot_number,
serial_number=serial_number,
production_run_id=production_run_id,
)
await db.commit()
return VisionExecuteResponse(
vision_result_id=result.id,
engine_version=result.engine_version,
measurements=[
VisionMeasurementResult(
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
)
for m in saved
],
)
# Composing a recipe and keeping a reference image are Maker operations, not
# measuring: both endpoints below gate on `require_maker`, not the
# `require_measurement_tec` that `execute` above uses.
@router.post("/reference-images")
async def add_reference_image(
task_id: int = Form(...),
image: UploadFile = File(...),
note: str | None = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
task = await _task_or_404(db, task_id)
content = await _read_image(image)
reference = await vision_service.save_reference_image(
db, task.id, content, note=note,
extension=_IMAGE_EXTENSIONS[image.content_type],
)
await db.commit()
return {"id": reference.id, "path": reference.path, "note": reference.note}
@router.post("/preview")
async def preview(
task_id: int = Form(...),
image: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
+3
View File
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
openrouter_api_key: str | None = None
openrouter_model: str = "anthropic/claude-sonnet-4"
# Vision worker (internal network only, reachable as `vision` in Compose)
vision_worker_url: str = "http://vision:8100"
@property
def database_url(self) -> str:
"""Async MySQL connection string."""
+2
View File
@@ -22,6 +22,7 @@ from src.backend.api.routers.statistics import router as statistics_router
from src.backend.api.routers.setup import router as setup_router
from src.backend.api.routers.stations import router as stations_router
from src.backend.api.routers.production import router as production_router
from src.backend.api.routers.vision import router as vision_router
@asynccontextmanager
@@ -75,6 +76,7 @@ app.include_router(reports_router)
app.include_router(setup_router)
app.include_router(stations_router)
app.include_router(production_router)
app.include_router(vision_router)
@app.get("/api/health")
@@ -0,0 +1,55 @@
"""the vision graph lives on the task, and the quote names its output
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
renaming costs one statement here; from the first saved task onwards it would
be a data migration and the typo would already be in the public API.
Revision ID: 011_vision_graph
Revises: 010_meas_authorisation
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '011_vision_graph'
down_revision: Union[str, None] = '010_meas_authorisation'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
with op.batch_alter_table('recipe_subtasks') as batch:
batch.add_column(
sa.Column('vision_output', sa.String(length=120), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table('recipe_subtasks') as batch:
batch.drop_column('vision_output')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('vision_json')
@@ -0,0 +1,79 @@
"""one row per vision execution, and camera as an input method
The vision surroundings do not go on `measurements`: statistics and the export
read that table on every pass. And it would be wrong modelling anyway - one
acquisition produces N quotes, and image, overlay, device and engine version are
the same for all of them.
Revision ID: 012_vision_results
Revises: 011_vision_graph
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '012_vision_results'
down_revision: Union[str, None] = '011_vision_graph'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_METHODS = ('usb_caliper', 'manual')
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
def upgrade() -> None:
op.create_table(
'vision_results',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
sa.Column('image_path', sa.String(length=500), nullable=True),
sa.Column('overlay_path', sa.String(length=500), nullable=True),
sa.Column('engine_version', sa.String(length=64), nullable=False),
sa.Column('executed_on',
sa.Enum('server', 'station', name='vision_executed_on_enum'),
nullable=False),
sa.Column('station_id', sa.Integer(),
sa.ForeignKey('stations.id'), nullable=True),
sa.Column('device_code', sa.String(length=100), nullable=True),
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('executed_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'input_method',
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
existing_nullable=False,
)
batch.add_column(
sa.Column('vision_result_id', sa.Integer(), nullable=True)
)
batch.create_foreign_key(
'fk_measurements_vision_result',
'vision_results', ['vision_result_id'], ['id'],
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.drop_constraint('fk_measurements_vision_result',
type_='foreignkey')
batch.drop_column('vision_result_id')
batch.alter_column(
'input_method',
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
existing_nullable=False,
)
op.drop_table('vision_results')
@@ -0,0 +1,49 @@
"""reference images, with their provenance
A reference image without provenance is a trap: the graph is re-run months later,
different numbers come out, and there is no way to tell an updated engine from a
moved lens.
`expected_json` holds what the graph is supposed to produce on this image. It is
what makes a regression run possible after a VisionSuite upgrade - the safety net
that makes bumping the submodule sustainable with an engine on two hosts.
Revision ID: 013_reference_images
Revises: 012_vision_results
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '013_reference_images'
down_revision: Union[str, None] = '012_vision_results'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'vision_reference_images',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
sa.Column('path', sa.String(length=500), nullable=False),
sa.Column('station_id', sa.Integer(),
sa.ForeignKey('stations.id'), nullable=True),
sa.Column('device_code', sa.String(length=100), nullable=True),
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
sa.Column('engine_version', sa.String(length=64), nullable=True),
sa.Column('note', sa.String(length=500), nullable=True),
sa.Column('expected_json', sa.JSON(), nullable=True),
sa.Column('acquired_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
def downgrade() -> None:
op.drop_table('vision_reference_images')
+5
View File
@@ -12,6 +12,11 @@ class MeasurementCreate(BaseModel):
value: float
lot_number: Optional[str] = Field(None, max_length=100)
serial_number: Optional[str] = Field(None, max_length=100)
# "camera" is deliberately absent from this pattern, even though the ORM
# enum and the database allow it. Do not add it here: a camera
# measurement must be produced by vision_service.execute_task, never by a
# client posting JSON straight to this endpoint - there is no image, no
# graph and no engine_version behind a value that arrived this way.
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0)
# The production this belongs to, when one is open at the station.
+6
View File
@@ -21,6 +21,7 @@ class SubtaskCreate(BaseModel):
ltl: Optional[float] = None
unit: str = Field("mm", max_length=20)
image_path: Optional[str] = Field(None, max_length=500)
vision_output: Optional[str] = None
class SubtaskUpdate(BaseModel):
@@ -35,6 +36,7 @@ class SubtaskUpdate(BaseModel):
ltl: Optional[float] = None
unit: Optional[str] = Field(None, max_length=20)
image_path: Optional[str] = Field(None, max_length=500)
vision_output: Optional[str] = None
class SubtaskResponse(BaseModel):
@@ -53,6 +55,7 @@ class SubtaskResponse(BaseModel):
ltl: Optional[float] = None
unit: str
image_path: Optional[str] = None
vision_output: Optional[str] = None
class TaskCreate(BaseModel):
@@ -64,6 +67,7 @@ class TaskCreate(BaseModel):
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
subtasks: list[SubtaskCreate] = []
@@ -76,6 +80,7 @@ class TaskUpdate(BaseModel):
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
class TaskResponse(BaseModel):
@@ -93,6 +98,7 @@ class TaskResponse(BaseModel):
file_path: Optional[str] = None
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
subtasks: list[SubtaskResponse] = []
+16
View File
@@ -0,0 +1,16 @@
"""Pydantic schemas for the vision execution response."""
from pydantic import BaseModel
class VisionMeasurementResult(BaseModel):
"""One quote's outcome, as much of it as the client needs to show."""
subtask_id: int
value: float
pass_fail: str
class VisionExecuteResponse(BaseModel):
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
vision_result_id: int
engine_version: str
measurements: list[VisionMeasurementResult]
+3
View File
@@ -7,6 +7,7 @@ from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.production import ProductionRun, ProductionEvent
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
__all__ = [
"User",
@@ -22,4 +23,6 @@ __all__ = [
"StationRecipeAssignment",
"ProductionRun",
"ProductionEvent",
"VisionResult",
"VisionReferenceImage",
]
+7 -1
View File
@@ -38,11 +38,17 @@ class Measurement(Base):
# Input method
input_method: Mapped[str] = mapped_column(
Enum("usb_caliper", "manual", name="input_method_enum"),
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
nullable=False,
default="manual",
)
# The vision execution that produced this value, when there was one. Null for
# everything taken by caliper or typed by hand.
vision_result_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("vision_results.id"), nullable=True
)
# Time spent by the operator entering this value (client-reported)
input_duration_ms: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
+11 -1
View File
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
# What a task is, declared by whoever writes the recipe instead of guessed from its
# contents. The last two are not built yet; they are in the enum from the start
# because widening a MySQL enum later is an ALTER on a live table.
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
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")
@@ -47,6 +47,10 @@ class RecipeTask(Base):
Enum("image", "pdf", name="file_type_enum"), nullable=True
)
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# The vision graph, serialised by vs-task. Opaque here on purpose: the
# 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)
# Relationships
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
@@ -89,6 +93,12 @@ class RecipeSubtask(Base):
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
# A link is an id, not a pointer - the same rule vs-task follows, and for
# the same reason: a pointer does not survive being written to disk.
vision_output: Mapped[Optional[str]] = mapped_column(
String(120), nullable=True
)
# Relationships
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
+69
View File
@@ -0,0 +1,69 @@
"""One row per vision execution - not per quote."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class VisionResult(Base):
__tablename__ = "vision_results"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
)
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
executed_on: Mapped[str] = mapped_column(
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
)
station_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("stations.id"), nullable=True
)
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Copied, not referenced: a calibration redone six months from now must not
# rewrite what a measurement already taken means. Same for the graph.
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
executed_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
def __repr__(self) -> str:
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
class VisionReferenceImage(Base):
"""An image kept so a graph can be composed, tried and re-tried without a
camera - which is exactly what VisionSuite's source abstraction is for."""
__tablename__ = "vision_reference_images"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
)
path: Mapped[str] = mapped_column(String(500), nullable=False)
station_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("stations.id"), nullable=True
)
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
acquired_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
def __repr__(self) -> str:
return f"<VisionReferenceImage task={self.task_id} path={self.path}>"
+23 -11
View File
@@ -224,8 +224,18 @@ async def save_measurement(
input_method: str = "manual",
input_duration_ms: int | None = None,
production_run_id: int | None = None,
vision_result_id: int | None = None,
enforce_tolerance_gate: bool = True,
) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail."""
"""Save a single measurement with auto-calculated pass/fail.
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
below - everything else (verdict, recipe rules, deviation) still applies.
This is for a caller that has already evaluated the gate itself for a whole
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
the gate models "moving on" between measurements taken one after another,
which does not exist inside a single camera acquisition.
"""
# Get subtask for tolerance values
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
@@ -240,17 +250,18 @@ async def save_measurement(
# No moving on while a quote is out of tolerance and unauthorised. Measuring
# that same quote again is the way out that does not need the capoturno; the
# next quote is not.
blocking = await pending_authorisation(
db, version_id, measured_by, production_run_id,
)
if blocking is not None and blocking.subtask_id != subtask_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A measurement is out of tolerance and awaiting the supervisor: "
"authorise it or measure that quote again before going on"
),
if enforce_tolerance_gate:
blocking = await pending_authorisation(
db, version_id, measured_by, production_run_id,
)
if blocking is not None and blocking.subtask_id != subtask_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A measurement is out of tolerance and awaiting the supervisor: "
"authorise it or measure that quote again before going on"
),
)
pass_fail, deviation = calculate_pass_fail(value, subtask)
@@ -266,6 +277,7 @@ async def save_measurement(
input_method=input_method,
input_duration_ms=input_duration_ms,
production_run_id=production_run_id,
vision_result_id=vision_result_id,
)
db.add(measurement)
await db.flush()
+6
View File
@@ -88,6 +88,11 @@ async def _copy_tasks_to_version(
file_path=task.file_path,
file_type=task.file_type,
annotations_json=task.annotations_json,
# Carried over explicitly, same reasoning as task_type above: a
# copy-on-write version that dropped the camera graph would leave
# a camera_measure task unable to execute from the moment a
# recipe with measurements is next edited.
vision_json=task.vision_json,
)
db.add(new_task)
await db.flush() # get new_task.id
@@ -105,6 +110,7 @@ async def _copy_tasks_to_version(
ltl=sub.ltl,
unit=sub.unit,
image_path=sub.image_path,
vision_output=sub.vision_output,
)
db.add(new_sub)
+234
View File
@@ -0,0 +1,234 @@
"""Route the image to the worker, map the outputs onto the quotes, save.
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
to the worker over HTTP and stays ignorant of how a number was produced.
"""
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import httpx
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
async def _call_worker(image_bytes: bytes, graph: dict) -> 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
table: "il worker isola il guasto, l'API resta in piedi, il task
riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job
past the timeout raises `httpx.ReadTimeout` - both would otherwise
propagate past this function and surface to the operator as a naked
500. The response shape is validated here too: a proxy answering 200
with something that is not the worker's JSON contract must not turn
into a bare KeyError further down.
"""
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)},
)
except httpx.ConnectError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
except httpx.ReadTimeout as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
) from exc
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:
report = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
) from exc
if not isinstance(report, dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: expected a JSON object",
)
missing = {"outputs", "failures", "engine_version"} - report.keys()
if missing:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
"the vision worker's answer was malformed: missing "
f"{', '.join(sorted(missing))}"
),
)
return report
async def execute_task(
db: AsyncSession,
task: RecipeTask,
image_bytes: bytes,
measured_by: int,
*,
version_id: int,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
# Ordered by marker_number - the number the operator sees on the annotated
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
# all key off it). Which quote is saved first must never be an accident of
# how SQLite/MySQL happens to return rows.
quotes = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.order_by(RecipeSubtask.marker_number)
)).scalars().all()
if not quotes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="no quote on this task names a vision output",
)
# Ruling R10: the gate is evaluated once per acquisition, not once per
# quote. `save_measurement`'s own gate models "moving on" between
# measurements taken one after another - a real later moment where the
# operator could leave a bad reading behind. A camera acquisition produces
# every quote of this task at the same instant: there is no "moving on"
# between them, so applying the sequential rule inside the batch would
# block on a fail this very request just saved. Check it once here,
# against the whole set this acquisition is about to produce, and pass
# enforce_tolerance_gate=False to every save_measurement call below.
#
# Checked before the worker runs (M3): a blocked operator finds out
# without burning a whole vision execution first - the worker is the
# expensive part, the gate check is not.
blocking = await measurement_service.pending_authorisation(
db, version_id, measured_by, production_run_id,
)
quote_ids = {q.id for q in quotes}
if blocking is not None and blocking.subtask_id not in quote_ids:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A measurement is out of tolerance and awaiting the supervisor: "
"authorise it or measure that quote again before going on"
),
)
report = await _call_worker(image_bytes, task.vision_json)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
# than a piece not measured at all.
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
if missing:
failed = ", ".join(f["tool_name"] for f in report["failures"])
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"the graph did not produce {', '.join(missing)}"
+ (f" (tools that failed: {failed})" if failed else "")
),
)
result = VisionResult(
task_id=task.id,
engine_version=report["engine_version"],
executed_on="server",
graph_snapshot=task.vision_json,
duration_ms=report.get("duration_ms"),
)
db.add(result)
await db.flush()
saved: list[Measurement] = []
for quote in quotes:
saved.append(await measurement_service.save_measurement(
db,
subtask_id=quote.id,
version_id=version_id,
measured_by=measured_by,
value=float(outputs[quote.vision_output]),
lot_number=lot_number,
serial_number=serial_number,
input_method="camera",
production_run_id=production_run_id,
vision_result_id=result.id,
enforce_tolerance_gate=False,
))
return result, saved
async def save_reference_image(
db: AsyncSession,
task_id: int,
image_bytes: bytes,
*,
note: str | None = None,
station_id: int | None = None,
device_code: str | None = None,
calibration_snapshot: dict | None = None,
extension: str = "png",
) -> VisionReferenceImage:
"""Keep an image so the graph can be composed and re-tried without a camera.
`extension` must match the upload's real content type (the router derives
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
would be a lie the filename tells forever.
"""
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
# rest of the codebase uses the former (see production_export_service).
folder = settings.upload_path / "vision" / "reference" / str(task_id)
folder.mkdir(parents=True, exist_ok=True)
name = f"{uuid4().hex}.{extension}"
(folder / name).write_bytes(image_bytes)
reference = VisionReferenceImage(
task_id=task_id,
path=str(Path("vision") / "reference" / str(task_id) / name),
note=note,
station_id=station_id,
device_code=device_code,
calibration_snapshot=calibration_snapshot,
)
db.add(reference)
await db.flush()
await db.refresh(reference)
return reference
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
"""Run the graph and save nothing. Composing is not measuring."""
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
return await _call_worker(image_bytes, task.vision_json)
+106
View File
@@ -0,0 +1,106 @@
"""Il server non importa VisionSuite, e questo lo verifica.
Senza qualcuno che lo controlli, il confine si perde in silenzio: basta un
import comodo perché l'immagine dell'API torni da cinque gigabyte e il motivo
per cui il worker esiste svanisca senza che nessuna prova diventi rossa.
"""
import ast
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
# Full dotted prefixes, not bare roots: "src" alone would forbid every import
# the backend legitimately makes of itself (src.backend.*), so the entries
# for the vision tree are multi-segment ("src.vision", "src.vision_worker")
# while VisionSuite's own top-level packages stay single-segment.
#
# M2: "Acquire" used to sit here as a stand-in for vs-camera, but it is a
# class/SDK name (Balluff's "mvIMPACT Acquire" SDK), never an import root -
# it could never match. vs-camera imports under the same `visionsuite`
# namespace as vs-core (see
# vendor/visionsuite/packages/vs-camera/pyproject.toml: "il codice si importa
# come visionsuite.camera...."), which "visionsuite" below already forbids,
# so there is nothing separate to add for it.
FORBIDDEN = {
"visionsuite", "pm2d", "dxf_compare", "torch",
"src.vision", "src.vision_worker",
}
def _imported_modules(source: Path) -> set[str]:
"""Full dotted module paths this file imports - not just their first
segment. `from src.vision.runner import ...` must be checked against
"src.vision.runner" (inside the forbidden "src.vision"), not against
"src" alone, which is never forbidden since the backend imports itself
constantly.
"""
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
modules.add(node.module)
return modules
def _forbidden_hits(modules: set[str]) -> set[str]:
"""Which FORBIDDEN entries a set of imported module paths triggers.
A module counts as forbidden if it equals a forbidden entry exactly, or
lives inside it ("src.vision.runner" is inside "src.vision").
"""
hits: set[str] = set()
for module in modules:
for entry in FORBIDDEN:
if module == entry or module.startswith(entry + "."):
hits.add(entry)
return hits
def test_the_server_never_imports_visionsuite():
offenders = []
for source in BACKEND.rglob("*.py"):
if "tests" in source.parts or "migrations" in source.parts:
continue
forbidden = _forbidden_hits(_imported_modules(source))
if forbidden:
offenders.append(f"{source.relative_to(BACKEND)}: {sorted(forbidden)}")
assert offenders == [], (
"il server deve restare cieco alla visione; usa il worker via HTTP:\n"
+ "\n".join(offenders)
)
def test_the_submodule_is_pinned():
"""Server e stazione devono montare lo stesso commit: qui c'è la fonte."""
gitmodules = Path(__file__).resolve().parents[3] / ".gitmodules"
assert gitmodules.exists(), "vendor/visionsuite non è un sottomodulo"
assert "vendor/visionsuite" in gitmodules.read_text(encoding="utf-8")
def test_a_full_module_path_import_is_caught_not_just_its_root(tmp_path):
"""M2: `from src.vision.runner import ...` inside src/backend/** must be
caught. Checking only the first dotted segment ("src") would miss it -
"src" itself is never forbidden, the backend imports itself all the time.
This is the blind spot the reviewer found: same repository, no install
needed, the most tempting shortcut of all.
"""
offender = tmp_path / "sneaky.py"
offender.write_text("from src.vision.runner import run_graph\n")
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision"}
def test_a_vision_worker_import_is_also_caught(tmp_path):
offender = tmp_path / "sneaky.py"
offender.write_text("import src.vision_worker.main\n")
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision_worker"}
def test_an_unrelated_src_backend_import_is_not_caught(tmp_path):
"""The fix must not turn every self-import of the backend into an
offender - only the vision subtrees are forbidden."""
innocent = tmp_path / "innocent.py"
innocent.write_text("from src.backend.config import settings\n")
assert _forbidden_hits(_imported_modules(innocent)) == set()
@@ -0,0 +1,167 @@
"""C1: the vision graph must survive copy-on-write, and creation must save it.
The spec assumed versioning was free for `vision_json`/`vision_output` - "un
campo in piu nella copia, non una logica nuova". `_copy_tasks_to_version`
copies an explicit field list that omitted both fields, so the first edit to
a recipe that already has measurements silently drops the camera graph from
the new version. The creation paths (`POST /api/recipes/{id}/tasks` and
`POST /api/tasks/{id}/subtasks`) have the same shape of bug: `TaskCreate` and
`SubtaskCreate` declare the fields, but the routers never pass them to the
ORM constructors, so the API answers 200 and silently does not store them.
Each test file here reads on its own - the same idiom `test_vision_graph.py`
and `test_recipes.py` already use.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
"""Give the current version a measurement, so an update takes the
copy-on-write path instead of updating in place."""
version_id = (await db.execute(
select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
subtask_id = (await db.execute(
select(RecipeSubtask.id)
.join(RecipeSubtask.task)
.where(RecipeSubtask.task.has(version_id=version_id))
)).scalar_one()
db.add(Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=user_id,
value=10.0,
pass_fail="pass",
deviation=0.0,
input_method="manual",
))
await db.flush()
async def _current_task_and_subtask(db: AsyncSession, recipe_id: int):
version = (await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
return version, task, subtask
@pytest.mark.asyncio
async def test_copy_on_write_preserves_the_vision_graph(
client: AsyncClient, db_session: AsyncSession, maker_user: User,
):
"""A recipe edit that triggers copy-on-write must not drop the camera graph."""
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, subtask = await _current_task_and_subtask(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.flush()
# A measurement on v1 forces the next PUT onto the copy-on-write path.
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": "Edit that must not drop the graph"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, new_subtask = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2, "the edit should have created v2"
assert new_task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
assert new_subtask.vision_output == "t1.diameter"
@pytest.mark.asyncio
async def test_creating_a_task_persists_its_vision_graph(
client: AsyncClient, db_session: AsyncSession, maker_user: User,
):
"""POST /api/recipes/{id}/tasks must store vision_json and vision_output,
not just accept and echo them back."""
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "Camera task",
"task_type": "camera_measure",
"vision_json": {"schema_version": 1, "name": "p", "tools": []},
"subtasks": [
{
"marker_number": 1,
"description": "Diameter",
"nominal": 10.0,
"vision_output": "t1.diameter",
}
],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["vision_json"] == {"schema_version": 1, "name": "p", "tools": []}
assert body["subtasks"][0]["vision_output"] == "t1.diameter"
# Reload from the database - the response reflects the ORM object right
# after flush, so this catches a constructor that dropped the field.
db_session.expunge_all()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == body["id"])
)).scalar_one()
sub = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
assert task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
assert sub.vision_output == "t1.diameter"
@pytest.mark.asyncio
async def test_creating_a_subtask_persists_its_vision_output(
client: AsyncClient, db_session: AsyncSession, maker_user: User,
):
"""POST /api/tasks/{id}/subtasks (the standalone endpoint) must also store it."""
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
resp = await client.post(
f"/api/tasks/{task.id}/subtasks",
headers=auth_headers(maker_user),
json={
"marker_number": 2,
"description": "Radius",
"nominal": 5.0,
"vision_output": "t1.radius",
},
)
assert resp.status_code == 201
assert resp.json()["vision_output"] == "t1.radius"
db_session.expunge_all()
sub = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.marker_number == 2)
)).scalar_one()
assert sub.vision_output == "t1.radius"
+329
View File
@@ -0,0 +1,329 @@
"""A quote from a camera is a measurement like any other: same verdict, same gate."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
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.services import measurement_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
GOOD = 10.0 # inside every limit of the fixture quote
OUT = 99.0 # far outside
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
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()
return version, task, subtask
async def two_vision_quotes(db_session, recipe_id: int):
"""The fixture recipe's task, with a second quote mapped to a vision output.
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
quotes belong to a single camera acquisition instead of two separate
manual readings.
"""
version, task, first = await recipe_parts(db_session, recipe_id)
first.vision_output = "t1.diameter"
second = RecipeSubtask(
task_id=task.id, marker_number=2, description="Second quote",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
vision_output="t1.radius",
)
db_session.add(second)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
await db_session.refresh(second)
return version, task, first, second
@pytest.fixture
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:
return {
"outputs": outputs,
"failures": failures or [],
"engine_version": "a" * 40,
"duration_ms": 12,
}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
@pytest.mark.asyncio
async def test_a_camera_quote_becomes_a_measurement(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
# The fixture creates the quote with nominal 10 and tight limits around it.
await db_session.commit()
worker_says({"t1.diameter": float(subtask.nominal)})
response = 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), "lot_number": "L1"},
)
assert response.status_code == 200
saved = (await db_session.execute(
select(Measurement).where(Measurement.subtask_id == subtask.id)
)).scalars().all()
assert len(saved) == 1
assert saved[0].pass_fail == "pass"
assert saved[0].input_method == "camera"
assert saved[0].vision_result_id is not None
@pytest.mark.asyncio
async def test_the_execution_records_which_engine_produced_it(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.diameter": float(subtask.nominal)})
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), "lot_number": "L1"},
)
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.engine_version == "a" * 40
assert result.executed_on == "server"
assert result.graph_snapshot == task.vision_json
@pytest.mark.asyncio
async def test_a_missing_output_names_itself(
client, db_session, measurement_tec_user, worker_says,
):
"""No half measurement: if an output is missing, the error names it."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.radius": 5.0})
response = 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), "lot_number": "L1"},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_task_without_a_graph_is_refused(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
await db_session.commit()
worker_says({})
response = 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 response.status_code == 422
assert "graph" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
# A camera acquisition produces every quote of the task at the same instant -
# there is no "moving on" between them, so the sequential gate must not fire
# inside the batch. It still has to fire between two separate acquisitions.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
client, db_session, measurement_tec_user, worker_says,
):
"""The quote evaluated first fails; the one after it must still be saved."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
response = 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 response.status_code == 200
by_subtask = {
m.subtask_id: m.pass_fail
for m in (await db_session.execute(select(Measurement))).scalars().all()
}
assert by_subtask == {first.id: "fail", second.id: "pass"}
@pytest.mark.asyncio
async def test_a_pending_fail_outside_the_batch_still_blocks(
client, db_session, measurement_tec_user, worker_says,
):
"""A fail left over from something else this camera task does not cover."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
# A third quote on the same task, not part of this acquisition (no vision
# output), already failed and is waiting on a supervisor.
third = RecipeSubtask(
task_id=task.id, marker_number=3, description="Not a vision quote",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db_session.add(third)
await db_session.commit()
await db_session.refresh(third)
await measurement_service.save_measurement(
db_session, subtask_id=third.id, version_id=version.id,
measured_by=measurement_tec_user.id, value=OUT,
)
await db_session.commit()
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
response = 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 response.status_code == 409
assert "supervisor" in response.json()["detail"].lower()
assert (await db_session.execute(
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
)).scalars().all() == []
@pytest.mark.asyncio
async def test_a_pending_fail_blocks_before_the_worker_is_called(
client, db_session, measurement_tec_user, monkeypatch,
):
"""M3: a blocked operator must not burn a whole vision execution to learn
they are blocked. The gate is checked before the worker runs, not after."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
third = RecipeSubtask(
task_id=task.id, marker_number=3, description="Not a vision quote",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db_session.add(third)
await db_session.commit()
await db_session.refresh(third)
await measurement_service.save_measurement(
db_session, subtask_id=third.id, version_id=version.id,
measured_by=measurement_tec_user.id, value=OUT,
)
await db_session.commit()
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> 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,
)
response = 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 response.status_code == 409
assert "supervisor" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
client, db_session, measurement_tec_user, worker_says,
):
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
first_shot = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p1.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert first_shot.status_code == 200
# first.id is now pending authorisation - but it belongs to this same
# task, so a new acquisition of it is not "moving on".
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
second_shot = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p2.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert second_shot.status_code == 200
readings = (await db_session.execute(
select(Measurement)
.where(Measurement.subtask_id == first.id)
.order_by(Measurement.id)
)).scalars().all()
assert [m.pass_fail for m in readings] == ["fail", "pass"]
assert await measurement_service.pending_authorisation(
db_session, version.id, measurement_tec_user.id,
) is None
+71
View File
@@ -0,0 +1,71 @@
"""Il grafo di visione vive sul task, e la quota sa quale uscita la riempie."""
import pytest
from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
from src.backend.tests.conftest import create_test_recipe
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone.
Versione corrente, il suo unico task, la sua prima quota. È l'idioma che
`test_out_of_tolerance.py` usa già, ripetuto perché ogni file di prova si
legge da solo.
"""
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
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()
return version, task, subtask
@pytest.mark.asyncio
async def test_task_carries_a_vision_graph(db_session, maker_user):
"""Il grafo si salva e si rilegge identico: il server non lo interpreta."""
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-1")
version, task, subtask = await recipe_parts(db_session, recipe.id)
graph = {
"schema_version": 1,
"name": "diametro del foro",
"tools": [{"id": "t1", "kind": "DetectCircle", "name": "foro"}],
}
task.vision_json = graph
task.task_type = "camera_measure"
await db_session.flush()
db_session.expunge_all()
reloaded = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == task.id)
)).scalar_one()
assert reloaded.vision_json == graph
@pytest.mark.asyncio
async def test_subtask_names_the_output_that_fills_it(db_session, maker_user):
"""La quota dichiara quale uscita del grafo le compete."""
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-2")
version, task, subtask = await recipe_parts(db_session, recipe.id)
subtask.vision_output = "t1.diameter"
await db_session.flush()
db_session.expunge_all()
reloaded = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask.id)
)).scalar_one()
assert reloaded.vision_output == "t1.diameter"
def test_the_typo_is_gone():
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
assert "dxf_compare" in TASK_TYPES
assert "xf_compare" not in TASK_TYPES
+110
View File
@@ -0,0 +1,110 @@
"""The Maker composes at a desk: image saved, graph tried, no measurement."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
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()
return version, task, subtask
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "b" * 40, "duration_ms": 7}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
"""Keep reference images out of the real repo tree during tests.
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
replaces the left operand of `/` when the right one is absolute - so
redirecting the bare `upload_dir` setting to pytest's absolute `tmp_path`
is enough to redirect the resolved path too.
"""
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.mark.asyncio
async def test_a_reference_image_keeps_its_provenance(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id), "note": "sample part"},
)
assert response.status_code == 200
saved = (await db_session.execute(
select(VisionReferenceImage)
)).scalars().one()
assert saved.task_id == task.id
assert saved.note == "sample part"
assert saved.acquired_at is not None
assert saved.path
@pytest.mark.asyncio
async def test_preview_runs_the_graph_and_saves_no_measurement(
client, db_session, maker_user, worker_says,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.diameter": 42.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
assert response.json()["outputs"]["t1.diameter"] == 42.0
# This is the whole point of this test: composing does not measure.
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@@ -0,0 +1,190 @@
"""I3: the three vision endpoints must not accept an unbounded or wrong-typed
upload. `/execute`, `/preview` and `/reference-images` read the whole image
into memory with no size cap, and `/reference-images` wrote whatever arrived
to disk with a `.png` extension regardless of its real type - a PDF is not
something a vision graph can measure.
Reuses the project's existing convention (`validate_file_size`,
`ALLOWED_IMAGE_TYPES` from `files.py`) instead of inventing a second one.
"""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
"""Keep the reference image this file stores out of the real repo tree.
The same guard `test_vision_reference.py` carries: without it, the test that
proves a jpeg is stored with its own extension leaves a real file under
`uploads/vision/reference/`, so every suite run dirties the working tree.
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
replaces the left operand of `/` when the right one is absolute - so
redirecting the bare `upload_dir` setting is enough to redirect the
resolved path too.
"""
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
def a_png() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
def a_jpeg() -> bytes:
buffer = io.BytesIO()
Image.new("RGB", (64, 64), color=0).save(buffer, format="JPEG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
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()
return version, task, subtask
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "c" * 40, "duration_ms": 5}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
@pytest.mark.asyncio
async def test_execute_rejects_a_non_image_upload(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.diameter": float(subtask.nominal)})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_execute_rejects_an_oversized_upload(
client, db_session, measurement_tec_user, worker_says, monkeypatch,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.diameter": float(subtask.nominal)})
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", a_png(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
@pytest.mark.asyncio
async def test_preview_rejects_a_non_image_upload(
client, db_session, maker_user, worker_says,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_reference_images_rejects_a_non_image_upload(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
assert (await db_session.execute(
select(VisionReferenceImage)
)).scalars().all() == []
@pytest.mark.asyncio
async def test_reference_image_keeps_the_extension_matching_its_real_type(
client, db_session, maker_user,
):
"""A jpeg upload must not be written to disk as `<uuid>.png`."""
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("p.jpg", a_jpeg(), "image/jpeg")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
assert saved.path.endswith(".jpg")
assert (settings.upload_path / saved.path).exists()
@@ -0,0 +1,117 @@
"""I2: a broken worker must answer with a named status, never a naked 500.
`_call_worker` mapped a non-200 HTTP response to 502, but let
`httpx.ConnectError` (worker stopped) and `httpx.ReadTimeout` (job past the
120s budget) propagate unhandled - with no exception handler in main.py that
turns those into anything but FastAPI's generic 500. It also read
`report["outputs"]`, `report["failures"]` and `report["engine_version"]`
without checking the response had that shape at all, so a proxy answering 200
with something else (HTML, an empty body) would raise a bare KeyError instead
of a diagnosable error.
The spec's edge-case table: "il worker isola il guasto, l'API resta in
piedi, il task riporta l'errore" - the worker's fault must not become the
API's fault.
"""
import httpx
import pytest
from fastapi import HTTPException
from src.backend.services.vision_service import _call_worker
class _FakeResponse:
"""Stands in for httpx.Response: only what `_call_worker` touches."""
def __init__(self, status_code: int, body=..., text: str = "", raise_on_json: bool = False):
self.status_code = status_code
self._body = body
self.text = text
self._raise_on_json = raise_on_json
def json(self):
if self._raise_on_json:
raise ValueError("not JSON")
return self._body
class _FakeAsyncClient:
"""Stands in for httpx.AsyncClient: post() does whatever the test wants."""
def __init__(self, *, response=None, raises=None):
self._response = response
self._raises = raises
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, *args, **kwargs):
if self._raises is not None:
raise self._raises
return self._response
def _install(monkeypatch, *, response=None, raises=None):
monkeypatch.setattr(
"src.backend.services.vision_service.httpx.AsyncClient",
lambda *a, **k: _FakeAsyncClient(response=response, raises=raises),
)
@pytest.mark.asyncio
async def test_a_stopped_worker_answers_502_not_a_naked_500(monkeypatch):
_install(monkeypatch, raises=httpx.ConnectError("Connection refused"))
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 502
assert "unreachable" in excinfo.value.detail.lower()
@pytest.mark.asyncio
async def test_a_job_past_the_time_budget_answers_504(monkeypatch):
_install(monkeypatch, raises=httpx.ReadTimeout("timed out"))
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 504
assert "time" in excinfo.value.detail.lower()
@pytest.mark.asyncio
async def test_a_response_missing_the_expected_keys_is_reported_not_raised_raw(monkeypatch):
"""A proxy answering 200 with an unexpected shape must not KeyError."""
_install(monkeypatch, response=_FakeResponse(200, body={"outputs": {}}))
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 502
assert "malformed" in excinfo.value.detail.lower()
@pytest.mark.asyncio
async def test_a_response_that_is_not_a_json_object_is_reported(monkeypatch):
"""200 with HTML or a bare list, say - not a dict at all."""
_install(monkeypatch, response=_FakeResponse(200, body=["not", "a", "dict"]))
with pytest.raises(HTTPException) as excinfo:
await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert excinfo.value.status_code == 502
assert "malformed" in excinfo.value.detail.lower()
@pytest.mark.asyncio
async def test_a_well_formed_response_still_passes_through(monkeypatch):
body = {"outputs": {"t1.diameter": 10.0}, "failures": [], "engine_version": "a" * 40}
_install(monkeypatch, response=_FakeResponse(200, body=body))
report = await _call_worker(b"img", {"schema_version": 1, "tools": []})
assert report == body
@@ -201,7 +201,7 @@
'measure': _('Misura'),
'drawing': _('Disegno'),
'note': _('Nota'),
'xf_compare': _('Confronto profilo'),
'dxf_compare': _('Confronto profilo'),
'camera_measure': _('Misura camera'),
} %}
{% set is_measure = task.task_type in ('measure', 'camera_measure') %}
+34 -2
View File
@@ -9,6 +9,7 @@ cdn...">` because it is the quickest way to try something, it works at their des
and nobody finds out until the machine is on the shop floor.
"""
import re
import subprocess
from pathlib import Path
import pytest
@@ -32,12 +33,43 @@ _DOC_HOSTS = (
)
def _tracked_files():
"""The files git knows about, or None when git cannot answer.
Being outside `vendor/` is not the same as being ours. A scratch copy of a
library dropped into `static/js/` to debug something looks first-party and is
not: it is not in the repository, it never reaches the shop floor, and its
comments are not our comments. Trackedness is the honest test for that.
Trackedness, and not `.gitignore`: an ignore pattern says nothing about a
file that is already tracked, so matching patterns would be the wrong
question.
When git cannot answer we scan everything, as before. A guard test that goes
quiet the moment it loses its footing is worse than one that cries wolf.
"""
try:
listed = subprocess.run(
["git", "ls-files", "-z"],
cwd=APP_ROOT, capture_output=True, text=True, check=True,
).stdout
except (OSError, subprocess.SubprocessError):
return None
return {(APP_ROOT / name).resolve() for name in listed.split("\0") if name}
def _first_party_files(suffixes):
tracked = _tracked_files()
def is_ours(path):
return tracked is None or path.resolve() in tracked
for path in sorted(STATIC.rglob("*")):
if path.suffix in suffixes and "vendor" not in path.parts:
if path.suffix in suffixes and "vendor" not in path.parts and is_ours(path):
yield path
for path in sorted(TEMPLATES.rglob("*.html")):
yield path
if is_ours(path):
yield path
def test_no_template_loads_anything_from_the_network():
View File
+129
View File
@@ -0,0 +1,129 @@
"""Graph plus image in, numbers out.
The one implementation of vision execution: the worker beside the server and
the station agent both call this, and they must agree to the digit. No HTTP, no
database, no filesystem beyond reading the pinned submodule's commit.
"""
from __future__ import annotations
import os
import subprocess
import time
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any
import numpy as np
from visionsuite.core.image import Image
from visionsuite.task import Task
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
@dataclass
class ToolFailure:
tool_id: str
tool_name: str
error: str
@dataclass
class RunOutcome:
outputs: dict[str, float] = field(default_factory=dict)
failures: list[ToolFailure] = field(default_factory=list)
engine_version: str = ""
duration_ms: int = 0
@lru_cache(maxsize=1)
def _engine_version_from_git() -> str:
"""Read the pinned commit from the submodule checkout.
Cached because the subprocess call is not free and the answer cannot
change within a run. Kept separate from `engine_version()` so that the
(cheap) environment-variable check below is never served a stale answer,
and so a test can force this path to fail without poisoning the cache
for real callers.
"""
try:
return subprocess.check_output(
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
text=True,
stderr=subprocess.PIPE,
).strip()
except (OSError, subprocess.CalledProcessError) as exc:
raise RuntimeError(
"engine_version: cannot determine which VisionSuite commit this "
"process is running. VISION_ENGINE_VERSION is not set, and "
f"`git -C {_SUBMODULE} rev-parse HEAD` failed ({exc}). "
"In the vision worker container this is expected - it has no "
"usable .git for the vendored submodule - so the image must be "
"built with `--build-arg VISION_ENGINE_VERSION=<commit>` "
"(see Dockerfile.vision). In a development checkout, make sure "
"the vendor/visionsuite submodule is initialised. Refusing to "
"guess: a station must not measure with an engine it cannot name."
) from exc
def engine_version() -> str:
"""The pinned VisionSuite commit: stamped on every number produced.
Resolution order: the VISION_ENGINE_VERSION environment variable (set at
container build time - Ruling R8, see Dockerfile.vision) if set and
non-empty; otherwise `git rev-parse HEAD` on the vendored submodule,
which works in a development checkout. Never falls back to a guess, an
empty string, or "unknown" - a station running an engine it cannot name
must refuse to measure rather than produce numbers nobody can trace.
"""
stamped = os.environ.get("VISION_ENGINE_VERSION", "").strip()
if stamped:
return stamped
return _engine_version_from_git()
def run_graph(
image: Any,
graph: dict,
calibration: dict | None = None,
) -> RunOutcome:
"""Run a serialised vs-task 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
back named, they are not swallowed.
`image` is whatever the caller already has in hand: a plain numpy array is
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
actually read via `.pixels`; an `Image` passed in is used as-is.
"""
task = Task.from_dict(graph)
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)
outputs: dict[str, float] = {}
failures: list[ToolFailure] = []
for result in report:
if not result.ok:
failures.append(ToolFailure(
tool_id=result.tool_id,
tool_name=result.tool_name,
error=result.error or "unknown failure",
))
continue
for name, value in result.outputs.items():
if isinstance(value, (int, float)):
outputs[f"{result.tool_id}.{name}"] = float(value)
return RunOutcome(
outputs=outputs,
failures=failures,
engine_version=engine_version(),
duration_ms=elapsed_ms,
)
View File
+110
View File
@@ -0,0 +1,110 @@
"""The runner: graph plus image in, numbers out. No HTTP, no database."""
import pytest
# The vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d declare it
# upstream, see pyproject.toml's `vision` extra), while backend and frontend
# stay on 3.11. This must run before `import numpy` - numpy itself is only
# installed alongside the vision extra - so that `uv run pytest` on the
# default 3.11 environment reports this module as skipped, with a reason,
# instead of erroring out of the whole collection.
pytest.importorskip(
"visionsuite.task",
reason=(
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
"declare it upstream); run it with "
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
),
)
import numpy as np
from src.vision import runner
from src.vision.runner import RunOutcome, engine_version, run_graph
def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
"""A light disc on a dark ground: a fake part, but a measurable one."""
image = np.zeros((size, size), dtype=np.uint8)
yy, xx = np.ogrid[:size, :size]
centre = size // 2
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
return image
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
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota.link("circle", found, "circle")
return task.to_dict()
def test_the_graph_produces_named_outputs():
outcome = run_graph(a_disc(), a_circle_graph())
assert isinstance(outcome, RunOutcome)
assert outcome.failures == []
# Keys are "tool id dot output name" - the same shape a subtask stores in
# vision_output.
assert any(key.endswith(".diameter") for key in outcome.outputs)
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".diameter"))
assert diameter == pytest.approx(120.0, abs=4.0)
def test_a_broken_tool_does_not_sink_the_others():
"""vs-task does not stop the run on an error: the runner reports it instead."""
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}}
)
outcome = run_graph(a_disc(), graph)
assert [f.tool_id for f in outcome.failures] == ["ghost"]
assert any(key.endswith(".diameter") for key in outcome.outputs)
def test_the_engine_says_which_version_it_is():
version = engine_version()
assert len(version) == 40 # a full git SHA
def test_engine_version_honours_the_environment_variable(monkeypatch):
"""A build-stamped VISION_ENGINE_VERSION wins over git, if set (Ruling R8:
the container has no usable .git for the vendored submodule, so the
worker image stamps the commit at build time instead of discovering it)."""
stamped = "a" * 40
monkeypatch.setenv("VISION_ENGINE_VERSION", stamped)
runner._engine_version_from_git.cache_clear()
try:
assert engine_version() == stamped
finally:
runner._engine_version_from_git.cache_clear()
def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
"""No env var and no resolvable git HEAD: refuse loudly. An engine that
cannot say what it is has no business producing measurements - it must
never fall back to a guess, an empty string, or "unknown"."""
monkeypatch.delenv("VISION_ENGINE_VERSION", raising=False)
runner._engine_version_from_git.cache_clear()
def fake_check_output(*args, **kwargs):
raise FileNotFoundError("git: command not found")
monkeypatch.setattr(runner.subprocess, "check_output", fake_check_output)
try:
with pytest.raises(RuntimeError, match="VISION_ENGINE_VERSION"):
engine_version()
finally:
runner._engine_version_from_git.cache_clear()
View File
+58
View File
@@ -0,0 +1,58 @@
"""The heavy container: the runner behind an internal API.
Separate from the FastAPI server on purpose. The API image stays light, a
VisionSuite upgrade does not restart production traffic, and an execution that
crashes does not take the other tablets' requests down with it.
"""
from __future__ import annotations
import json
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image
from src.vision.runner import engine_version, run_graph
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
) -> dict:
try:
parsed_graph = json.loads(graph)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph is not valid JSON: {exc}",
) from exc
frame = np.array(Image.open(image.file).convert("L"))
try:
outcome = run_graph(frame, parsed_graph)
except ValueError as exc:
# from_dict refuses a schema version it does not handle; that is a bad
# request, not a server fault.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return {
"outputs": outcome.outputs,
"failures": [
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
for f in outcome.failures
],
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
}
View File
+97
View File
@@ -0,0 +1,97 @@
"""The worker is a thin shell: it receives an image and a graph, and answers
with numbers."""
import io
import json
import pytest
# Same reasoning as src/vision/tests/test_runner.py: the vision tree (and
# therefore this worker, which imports src.vision.runner) requires Python
# >=3.13. This must run before `import numpy` / `from src.vision_worker.main
# import app` so that `uv run pytest` on the default 3.11 environment reports
# this module as skipped, with a reason, instead of erroring out of
# collection.
pytest.importorskip(
"visionsuite.task",
reason=(
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
"declare it upstream); run it with "
"`uv run --python 3.13 --extra vision-worker --extra dev pytest "
"src/vision_worker/tests`"
),
)
import numpy as np
from httpx import ASGITransport, AsyncClient
from PIL import Image
from src.vision_worker.main import app
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
image = np.zeros((size, size), dtype=np.uint8)
yy, xx = np.ogrid[:size, :size]
centre = size // 2
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
buffer = io.BytesIO()
Image.fromarray(image).save(buffer, format="PNG")
return buffer.getvalue()
def a_circle_graph() -> dict:
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota.link("circle", found, "circle")
return task.to_dict()
@pytest.mark.asyncio
async def test_health_says_which_engine_it_carries():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.get("/health")
assert response.status_code == 200
assert len(response.json()["engine_version"]) == 40
@pytest.mark.asyncio
async def test_run_returns_the_named_outputs():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
assert response.status_code == 200
body = response.json()
assert body["failures"] == []
assert any(key.endswith(".diameter") for key in body["outputs"])
# Pinned to src/vision/tests/test_runner.py's in-process expectation
# (same graph, same image, same tolerance) - the whole point of a shared
# engine_version is that the number does not depend on which path
# computed it.
diameter = next(v for k, v in body["outputs"].items() if k.endswith(".diameter"))
assert diameter == pytest.approx(120.0, abs=4.0)
@pytest.mark.asyncio
async def test_a_malformed_graph_is_refused_not_crashed():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
)
assert response.status_code == 422
assert "99" in response.json()["detail"]
Generated
+322 -1
View File
@@ -941,6 +941,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" },
]
[[package]]
name = "llvmlite"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/27/72ae94ea5c8f7349ec1c229d4cd058feb799cbd0833ad6d1b47c919b37b7/llvmlite-0.49.0.tar.gz", hash = "sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a", size = 194467, upload-time = "2026-08-11T16:26:00.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/d0/ab52de2328e97ca96cdf0331a5f774796bddc420a51768f4501193f80cbb/llvmlite-0.49.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:4b0e710880b7cc910392bd6b9f1bbf468fed99b182e4420d51598f36114b3dce", size = 40479230, upload-time = "2026-08-11T16:23:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/1f/80/0989432d12b7c86a6f5f380eb92eca7de779af9b34dedbd311b694d7da8d/llvmlite-0.49.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a8c0fc9d624bdc30a3d2db11eb2fb98f80fb209d20b37604eda516cd9b699cf4", size = 59890659, upload-time = "2026-08-11T16:23:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/58/e9/76859ca36aaa460b6ae0508e01637f0e9bdb9b59faaa4637ade3b94bbcca/llvmlite-0.49.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20496a5c9fdb8179fb9300e7d19f6782555d98aeeb4a322264aa7fd99f980618", size = 58344482, upload-time = "2026-08-11T16:23:44.199Z" },
{ url = "https://files.pythonhosted.org/packages/7d/49/47cd23e05d52d117b6119871ec299adedc9d8d332a2296964d9b2adc06d9/llvmlite-0.49.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a5b06c1b5fc4ae4c9b169b065f42b719448ef1f873687ef224ef69969b75ec3", size = 41865253, upload-time = "2026-08-11T16:23:50.198Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/3f699ebe3590e15e023a6372dd147526fd8ec398aacf9ceb844e854964a8/llvmlite-0.49.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe", size = 40479231, upload-time = "2026-08-11T16:23:56.773Z" },
{ url = "https://files.pythonhosted.org/packages/be/3c/e97f69c62a2d972066d9a2612ce1f3de313035ac897a5b9f787cad8b55f7/llvmlite-0.49.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870", size = 59890658, upload-time = "2026-08-11T16:24:05.451Z" },
{ url = "https://files.pythonhosted.org/packages/69/e6/e942ee08605fc0526ff3854260c384d8315a5830e16c4c2a5aebc14dc9bf/llvmlite-0.49.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68", size = 58344481, upload-time = "2026-08-11T16:24:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/84/49/2a44871cac6b5a2fd4aabd68cfdaf6de9a5c7edb36dee5d47b77bda4b50f/llvmlite-0.49.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3", size = 41865543, upload-time = "2026-08-11T16:24:20.41Z" },
{ url = "https://files.pythonhosted.org/packages/7d/85/0b536a3c59f2636d9dd51dda832b6c1d0ffec37608429dedf128664918f1/llvmlite-0.49.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc", size = 40479230, upload-time = "2026-08-11T16:24:27.295Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1c/ca8ba47b057b793099784475499771780ec46839f2782f753a7079d23520/llvmlite-0.49.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3", size = 59890659, upload-time = "2026-08-11T16:24:35.595Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/9526dfdd33a923f33e29a18b8f9801ee7ee4b7397e88d28192c1024c4a75/llvmlite-0.49.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038", size = 58344482, upload-time = "2026-08-11T16:24:45.79Z" },
{ url = "https://files.pythonhosted.org/packages/96/7f/9f5afcf6476b228d6b170408f377a0c4f91477fc1fc91f8141088b45bf46/llvmlite-0.49.0-cp313-cp313-win_amd64.whl", hash = "sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2", size = 41865544, upload-time = "2026-08-11T16:24:53.962Z" },
{ url = "https://files.pythonhosted.org/packages/25/d1/16599b8c9f21802448059482eab48a9e74086dc56b901a677ba355565e64/llvmlite-0.49.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8", size = 40479230, upload-time = "2026-08-11T16:25:01.513Z" },
{ url = "https://files.pythonhosted.org/packages/3a/61/0b23849141a4c4e7091fcd158ebb45195896974bebca3e58fee7cad4b4f4/llvmlite-0.49.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5", size = 59890659, upload-time = "2026-08-11T16:25:08.733Z" },
{ url = "https://files.pythonhosted.org/packages/c4/92/628692b74b31e27af9ba7e8ba651941ee4956959d5478123c453f59aad4a/llvmlite-0.49.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69", size = 58344479, upload-time = "2026-08-11T16:25:15.731Z" },
{ url = "https://files.pythonhosted.org/packages/96/8a/412fc273521b02cbfe0b5f8ad56cc696385f6eaeecdb9e9ae6a90111d98d/llvmlite-0.49.0-cp314-cp314-win_amd64.whl", hash = "sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9", size = 42986588, upload-time = "2026-08-11T16:25:22.534Z" },
{ url = "https://files.pythonhosted.org/packages/fc/15/f47cf45c00c8b165ac3d268502dcb21d900e86f27fd338268a66ce922ab0/llvmlite-0.49.0-cp314-cp314-win_arm64.whl", hash = "sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81", size = 37441881, upload-time = "2026-08-11T16:25:28.324Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2e/eafd488766d1c02413cba24f7b22acb9b3ccdfd8407e98d30eb16bac4e2a/llvmlite-0.49.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72", size = 40479230, upload-time = "2026-08-11T16:25:35.125Z" },
{ url = "https://files.pythonhosted.org/packages/98/07/a2c4f04e2111ccc274b4d5e3331398a9dcf6d6e5e55d6444b1ad9d6381cf/llvmlite-0.49.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4", size = 59890658, upload-time = "2026-08-11T16:25:43.294Z" },
{ url = "https://files.pythonhosted.org/packages/80/f9/7b7b50f80b4585bcd78675ff3110c256877b11df32a8cde284f851762f57/llvmlite-0.49.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82", size = 58344482, upload-time = "2026-08-11T16:25:51.527Z" },
{ url = "https://files.pythonhosted.org/packages/d8/c6/32d68bfbf1d0c36888530ef6fd72864861af23dc546302b41033471a8c3a/llvmlite-0.49.0-cp314-cp314t-win_amd64.whl", hash = "sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9", size = 42986602, upload-time = "2026-08-11T16:25:57.69Z" },
]
[[package]]
name = "logistro"
version = "2.0.1"
@@ -1045,6 +1074,131 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
]
[[package]]
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'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/8c/ed/55ba4e54ee878396de6b18e6533cc4a92fa519e8c82d55cf40f98c0a6831/numba-0.67.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3fa3d1b27f96f2c0d54513d953d7197886aa1eaa7d2439a0eedc44d993fb181a", size = 2744821, upload-time = "2026-08-11T23:03:17.321Z" },
{ url = "https://files.pythonhosted.org/packages/be/78/3f3c45dbaec3cf02bbb1825731beca50f591227e95143d6bd7a64897641c/numba-0.67.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c80c847301dc33dc8f84a97a952004023d9a05578ae4512b087176264cc1960", size = 3827182, upload-time = "2026-08-11T23:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/4e70cb86534283d859c3aea2302da523e41539b98dd6c3c4d0a42af95cda/numba-0.67.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7a7b0121466f1e9a8a074b0545fe90e16389623abf979b5d7c299dca1294d7e", size = 3532817, upload-time = "2026-08-11T23:03:22.06Z" },
{ url = "https://files.pythonhosted.org/packages/26/4d/23dab7f4233be0fc34f54a169ed85238467cd24d8adf2498e5c12ea19dc7/numba-0.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfba1ac34f0363fb1a250a10e97240780d11e05227892f7286b26fbfd0ad58ce", size = 2815700, upload-time = "2026-08-11T23:03:23.812Z" },
{ url = "https://files.pythonhosted.org/packages/0d/58/915cddba90010348ed0444451132fdde9b000bcbaff1582029b5bf115d11/numba-0.67.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219", size = 2745050, upload-time = "2026-08-11T23:03:25.607Z" },
{ url = "https://files.pythonhosted.org/packages/bb/38/926757caaac18a66f057d7544a63620bf360a07d281c9f7ecadd2aa83963/numba-0.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa", size = 3884596, upload-time = "2026-08-11T23:03:27.688Z" },
{ url = "https://files.pythonhosted.org/packages/4d/6d/58291dc58da39d98b32db7f044729f6d8d4920cd9622fbab3179b54ff4c4/numba-0.67.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5", size = 3585290, upload-time = "2026-08-11T23:03:29.684Z" },
{ url = "https://files.pythonhosted.org/packages/6e/63/ab21828b4056afed71f9ecb40f4de26c2c19de731cc001961aca74b79464/numba-0.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81", size = 2815645, upload-time = "2026-08-11T23:03:31.732Z" },
{ url = "https://files.pythonhosted.org/packages/49/dd/bd9fe772f6c84597b76cac229b3f2890f01a2c64fd70e48ceaae10dd65cb/numba-0.67.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b", size = 2744872, upload-time = "2026-08-11T23:03:33.649Z" },
{ url = "https://files.pythonhosted.org/packages/a1/1c/c05609739cc41116d36e30cb2b41fb00f126bb52e1b0bac907051ad8a35d/numba-0.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0", size = 3892004, upload-time = "2026-08-11T23:03:35.797Z" },
{ url = "https://files.pythonhosted.org/packages/4a/77/a5276ad4178250403e0e2251f3e1f8ac18feac779b0474a8bcb08558490d/numba-0.67.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2", size = 3591878, upload-time = "2026-08-11T23:03:37.845Z" },
{ url = "https://files.pythonhosted.org/packages/0f/80/d48f0ba7442516ceb5a1585f0c81d3aa531bc96bfcabcd9f8f925768c426/numba-0.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd", size = 2815504, upload-time = "2026-08-11T23:03:39.736Z" },
{ url = "https://files.pythonhosted.org/packages/d7/16/345b1e4774a08247aafcfdb93d4e8d24a3646366cbe72de33053fc0de1b5/numba-0.67.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b", size = 2745088, upload-time = "2026-08-11T23:03:41.864Z" },
{ url = "https://files.pythonhosted.org/packages/1b/36/e614ba2bc0f005ed0f37a6413f08fe705210297ddb9a37a475a8b9fdab61/numba-0.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd", size = 3861040, upload-time = "2026-08-11T23:03:43.842Z" },
{ url = "https://files.pythonhosted.org/packages/40/90/30c42a1dbc4176cf355e8e8be61803732c55597b1332925fe233912a43d9/numba-0.67.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7", size = 3561811, upload-time = "2026-08-11T23:03:46.037Z" },
{ url = "https://files.pythonhosted.org/packages/18/6d/21bd16f770476e394c5e5f504935817967442a71251d6b86c244a2767980/numba-0.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4", size = 2817421, upload-time = "2026-08-11T23:03:47.922Z" },
{ url = "https://files.pythonhosted.org/packages/95/06/bb41b0e59b9ff52c94a2f01db24f6437df058caebb377b5f372fc343a6a2/numba-0.67.0-cp314-cp314-win_arm64.whl", hash = "sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f", size = 2788885, upload-time = "2026-08-11T23:03:49.967Z" },
{ url = "https://files.pythonhosted.org/packages/10/7c/aa07151fbd0f4283f78de437cc196f9084789be89a2b4de3fdc2f6a4b414/numba-0.67.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61", size = 2748150, upload-time = "2026-08-11T23:03:51.755Z" },
{ url = "https://files.pythonhosted.org/packages/74/62/b8174ca95a4cc1a7ba1520767734e016991545590b8fbde521b681701a9f/numba-0.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6", size = 3896986, upload-time = "2026-08-11T23:03:53.752Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f9/3a7b6dbf81e01a48958b45ad2239edbc64707522ab17f11f9f18c44bf6d1/numba-0.67.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b", size = 3614644, upload-time = "2026-08-11T23:03:55.794Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5b/248f5681c121ca853a9f4e39d342a3e01b8a0261b0275853eb3d0d56aa20/numba-0.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e", size = 2822870, upload-time = "2026-08-11T23:03:58.097Z" },
]
[[package]]
name = "numpy"
version = "2.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" },
{ url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" },
{ url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" },
{ url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" },
{ url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" },
{ url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" },
{ url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" },
{ url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" },
{ url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" },
{ url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" },
{ url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" },
{ url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" },
{ url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" },
{ url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" },
{ url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" },
{ url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" },
{ url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" },
{ url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
{ url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
{ url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
{ url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
{ url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
{ url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
{ url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
{ url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
{ url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
{ url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
{ url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
{ url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
{ url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
{ url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
{ url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
{ url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
{ url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
{ url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
{ url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
{ url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
{ url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
{ url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
{ url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
]
[[package]]
name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
]
[[package]]
name = "orjson"
version = "3.11.8"
@@ -1592,6 +1746,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" },
{ url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" },
{ url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" },
{ url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" },
{ url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" },
{ url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" },
{ url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" },
{ url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" },
{ url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" },
{ url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" },
{ url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" },
{ url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" },
{ url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" },
{ url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" },
{ url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" },
{ url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" },
{ url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" },
{ url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" },
{ url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" },
{ url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" },
{ url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" },
{ url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" },
]
[[package]]
name = "simplejson"
version = "4.1.1"
@@ -1769,6 +1974,24 @@ server = [
{ name = "uvicorn", extra = ["standard"] },
{ name = "weasyprint" },
]
vision = [
{ 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 = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
]
vision-worker = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
{ name = "numpy", 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 = "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'" },
]
[package.metadata]
requires-dist = [
@@ -1777,6 +2000,7 @@ requires-dist = [
{ name = "asyncmy", marker = "extra == 'server'", specifier = ">=0.2.0" },
{ name = "bcrypt", marker = "extra == 'server'", specifier = ">=4.0.0" },
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "fastapi", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.110.0" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110.0" },
{ name = "flask", marker = "extra == 'client'", specifier = ">=3.0.0" },
{ name = "flask-babel", marker = "extra == 'client'", specifier = ">=4.0.0" },
@@ -1786,7 +2010,9 @@ requires-dist = [
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'vision'" },
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
{ name = "pillow", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=10.0.0" },
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
@@ -1794,14 +2020,21 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.0.6" },
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.6" },
{ name = "requests", marker = "extra == 'client'", specifier = ">=2.31.0" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2.0.0" },
{ name = "tiemeasureflow", extras = ["vision"], marker = "extra == 'vision-worker'" },
{ name = "urllib3", marker = "extra == 'client'", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "python_full_version >= '3.13' and extra == 'vision-worker'", specifier = ">=0.30.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.30.0" },
{ name = "visionsuite", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-core" },
{ 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 = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
]
provides-extras = ["server", "client", "dev"]
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
[[package]]
name = "tinycss2"
@@ -1919,6 +2152,94 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
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'" },
]
[package.metadata]
requires-dist = [
{ name = "ezdxf", marker = "extra == 'dxf'", specifier = ">=1.0" },
{ name = "matplotlib", marker = "extra == 'dev'" },
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
]
provides-extras = ["dev", "fast", "dxf"]
[[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'" },
]
[package.metadata]
requires-dist = [
{ 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"]
[[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'" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "numba", specifier = ">=0.65.0" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "python-multipart", specifier = ">=0.0.26" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
]
[package.metadata.requires-dev]
dev = [{ name = "httpx", specifier = ">=0.28.1" }]
[[package]]
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'" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
]
provides-extras = ["dev"]
[[package]]
name = "watchfiles"
version = "1.1.1"
Vendored Submodule
+1
Submodule vendor/visionsuite added at f095fcc254