diff --git a/.gitignore b/.gitignore index 347a9a9..4301c01 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ docker-compose.override.yml # Subagent-driven development scratch: ledger, briefs, review packages .superpowers/ + +# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point +packaging/windows/_strumenti.py diff --git a/CLAUDE.md b/CLAUDE.md index 7e79fb4..9e5e1c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`): - ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni. - ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench. - ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`. +- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa). - ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo. - ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet. - ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe. diff --git a/packaging/windows/avvio.py b/packaging/windows/avvio.py index b2263d5..7611f5a 100644 --- a/packaging/windows/avvio.py +++ b/packaging/windows/avvio.py @@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`. Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove automatiche; NON salta il controllo di licenza) e `--senza-browser`. +`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni +ne' database; registra gli strumenti, scrive su stdout e in +%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON +{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3. +La usa build.ps1 prima di Inno Setup. Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic. @@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai ⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni. ⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss). +⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli +strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py` +(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82). """ from __future__ import annotations import argparse import ctypes +import importlib import json import logging import os @@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None: pass +# ---------------------------------------------------------------- strumenti di visione + +def registra_strumenti() -> list[str]: + """Registra gli strumenti di Core; torna i moduli che non si sono importati (#82). + + Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e + l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente + `_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread + di questo processo: registrare una volta sola qui vale per tutti.""" + from visionsuite.task.registry import load_tools + load_tools() + try: + from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py + except ImportError: + MODULI = () # da sorgente: gli entry point di load_tools() bastano + falliti: list[str] = [] + for modulo in MODULI: + try: + importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool + except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie + log.error("strumenti %s non importati", modulo, exc_info=True) + falliti.append(modulo) + return falliti + + +def controlla_strumenti() -> int: + """Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti.""" + from visionsuite.task.graph import registry + from visionsuite.task.registry import unavailable + falliti = registra_strumenti() + kind = sorted(registry()) + esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()} + riga = json.dumps(esito, ensure_ascii=False) + if sys.stdout is not None: + print(riga) + try: + f = cartella_dati() / "log" / "controllo-strumenti.json" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura + except OSError as e: + log.warning("controllo-strumenti.json non scritto: %s", e) + return 0 if kind and not falliti else 3 + + # ---------------------------------------------------------------- rete def _porta_aperta(porta: int) -> bool: @@ -402,6 +455,8 @@ def main(argv=None) -> int: p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)") p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)") p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)") + p.add_argument("--controlla-strumenti", action="store_true", + help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)") args = p.parse_args(argv) _prepara_log() @@ -415,6 +470,9 @@ def main(argv=None) -> int: messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre) return 0 + if args.controlla_strumenti: + return controlla_strumenti() # prima della licenza: la build non ha una licenza + if not controlla_licenza(args.senza_finestre): return 1 @@ -449,6 +507,7 @@ def main(argv=None) -> int: _prepara_log(dati) # alembic ha riconfigurato il logging log.info("migrazioni applicate (primo avvio: %s)", primo_avvio) + registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82) thread = avvia_server(args.host, dati) if not args.senza_browser: # database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env) diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 074039c..88d937c 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in ⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False): funziona ma e' piu' lento nel matching. +⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i +metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera +packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di +Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce. ⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`): si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo. #> @@ -85,8 +89,9 @@ try { $pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))" if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" } $includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" }) -# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una -# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello. +# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui +# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che +# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82). Write-Host "Pacchetti visionsuite: $($includi -join ' ')" # Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..." @@ -103,6 +108,25 @@ if (Test-Path $leggimi) { # Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH $env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';') +# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i +# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`. +$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py' +$genera = @' +import sys +from importlib.metadata import entry_points +moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")}) +if not moduli: + sys.exit(1) +righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("] +righe += [f" {m!r}," for m in moduli] + [")"] +with open(sys.argv[1], "w", encoding="utf-8", newline="") as f: + print(*righe, sep=chr(10), file=f) +print(len(moduli)) +'@ +$nModuli = $genera | & $py - $strumenti +if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" } +Write-Host "Moduli-strumento ($nModuli) in $strumenti" + $out = Join-Path $radice 'build\nuitka' New-Item -ItemType Directory -Force $out | Out-Null $nuitka = @( @@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File | Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' } if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" } +# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB) +$exe = Join-Path $sorgente 'TieMeasureFlow.exe' +$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json' +Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo +# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait +$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru +$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' } +if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" } +$nStrumenti = ($esito | ConvertFrom-Json).strumenti +Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe" + if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return } $dist = Join-Path $radice 'dist' diff --git a/packaging/windows/tests/test_avvio.py b/packaging/windows/tests/test_avvio.py index 04226e9..3c5e852 100644 --- a/packaging/windows/tests/test_avvio.py +++ b/packaging/windows/tests/test_avvio.py @@ -13,6 +13,8 @@ import importlib.util import json import os import sqlite3 +import sys +import types from datetime import date, timedelta from pathlib import Path @@ -195,3 +197,77 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av avvio.main(["--senza-finestre", "--senza-browser"]) assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1" assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono + + +# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) --- + +def _modulo_strumenti(monkeypatch, moduli): + # finto _strumenti.py generato da build.ps1 + finto = types.ModuleType("_strumenti") + finto.MODULI = tuple(moduli) + monkeypatch.setitem(sys.modules, "_strumenti", finto) + + +def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch): + # _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point + monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import + from visionsuite.task.graph import registry + assert avvio.registra_strumenti() == [] + assert len(registry()) > 0 + + +def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch): + _modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste")) + assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato + + +def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch): + # senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome + _modulo_strumenti(monkeypatch, ("visionsuite.tools.image",)) + visti = [] + monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome)) + assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"] + + +def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path): + monkeypatch.setitem(sys.modules, "_strumenti", None) + + # la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire + def mai(*a, **k): + raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti") + + monkeypatch.setattr(avvio.lic, "verifica", mai) + monkeypatch.setattr(avvio.lic, "cerca_licenza", mai) + monkeypatch.setattr(avvio, "avvia_server", mai) + monkeypatch.setattr(avvio, "esegui_migrazioni", mai) + assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0 + dati = json.loads(capsys.readouterr().out) + assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"]) + assert dati["falliti"] == [] + scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json" + assert json.loads(scritto.read_text(encoding="utf-8")) == dati + assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB + + +def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys): + _modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",)) + assert avvio.main(["--controlla-strumenti"]) == 3 + assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"] + + +def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys): + # niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire + import visionsuite.task.graph as graph + monkeypatch.setattr(avvio, "registra_strumenti", lambda: []) + monkeypatch.setattr(graph, "registry", lambda: {}) + assert avvio.main(["--controlla-strumenti"]) == 3 + assert json.loads(capsys.readouterr().out)["strumenti"] == 0 + + +def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch): + # main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server + _scrivi_licenza(tmp_path) + monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or []) + assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0 + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]