Compare commits

...

14 Commits

Author SHA1 Message Date
Adriano f8f5f248ff setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80)
_stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop
di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo
test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:25:24 +02:00
Adriano 0e92959022 Merge piano C: setup Windows di TieMeasureFlow (TieMeasureFlow#76)
rilascio-windows / setup-windows (push) Successful in 32m46s
2026-10-17 12:39:35 +02:00
Adriano f23e87e585 Merge piano B: report PDF su Windows offline (TieMeasureFlow#78) 2026-10-17 12:39:35 +02:00
Adriano 114e4a10ae Merge piano A: backend su SQLite (TieMeasureFlow#77) 2026-10-17 12:39:34 +02:00
Adriano 3aac8cc490 CLAUDE.md: trappole del pacchetto Windows (stazione, alembic, init-db) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:28:12 +02:00
Adriano 8b3e897668 Workflow rilascio-windows: corregge il percorso di Node nel PATH (YAML valido) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:25:22 +02:00
Adriano eea3e319fb Pacchetto Windows: STATION_CODE predefinito e chiavi extra da config.env, alembic.ini sourceless, fileConfig neutralizzato (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:55:48 +02:00
Adriano bfc7c391e8 build.ps1: niente metadati vs-* (Nuitka li rifiuta per i pacchetti del namespace visionsuite) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:29:45 +02:00
Adriano 8d4c4020f6 Pacchetto Windows: tzdata nel gruppo windows, flag Nuitka dal LEGGIMI del piano PDF, trappole in CLAUDE.md (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:20:58 +02:00
Adriano 0a68a9995e Pacchetto Windows: LEGGIMI dei report PDF (DLL, variabili, flag Nuitka, licenze) (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:18:21 +02:00
Adriano 959e92552b Pacchetto Windows: test dell'avvio, workflow rilascio-windows, sezione README (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:17:18 +02:00
Adriano 8431aa7095 Report PDF su Windows: MathJax locale per Kaleido (niente CDN offline) e test con WeasyPrint/Edge veri (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:16:29 +02:00
Adriano 23012fdfbb Pacchetto Windows: prendi-gtk.ps1 scarica le DLL Pango/GTK pinnate con sha256 (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:10:11 +02:00
Adriano 242add047b Backend su file SQLite locale: DB_URL, PRAGMA FK/WAL, UPLOAD_DIR assoluto (TieMeasureFlow#77)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:45 +02:00
18 changed files with 1187 additions and 20 deletions
+119
View File
@@ -0,0 +1,119 @@
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
# artifact e come asset della release Gitea del tag.
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
name: rilascio-windows
on:
push:
tags: ['v*']
workflow_dispatch: {}
jobs:
setup-windows:
runs-on: windows
timeout-minutes: 180
defaults:
run:
shell: powershell
steps:
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
- uses: actions/checkout@v4
- name: Submodule privati (deploy key in sola lettura)
env:
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
run: |
$ErrorActionPreference = 'Stop'
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $ssh | Out-Null
$chiave = Join-Path $ssh 'chiave'
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
# con la sola lettura Remove-Item finale non puo' cancellarla
icacls $chiave /inheritance:r | Out-Null
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
$known = Join-Path $ssh 'known_hosts'
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
try {
git submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
} finally {
# la deploy key non resta mai sul disco del runner
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
}
- name: uv (se manca)
run: |
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
irm https://astral.sh/uv/install.ps1 | iex
}
# il PATH del passo successivo
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Node (se manca)
run: |
# serve a Tailwind (npx) in build.ps1
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
}
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Inno Setup (se manca)
run: |
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
if (-not $trovato) {
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
}
- name: Build (Nuitka + Inno Setup)
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
# un nome senza numeri, quindi in quel caso si passa 0.0.0
run: |
$v = '${{ github.ref_name }}'
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
- name: Setup come artifact
uses: actions/upload-artifact@v3
with:
name: TieMeasureFlow-setup
path: dist/TieMeasureFlow-setup-*.exe
if-no-files-found: error
- name: Asset della release Gitea (solo ai tag)
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
$ErrorActionPreference = 'Stop'
$h = @{ Authorization = "token $env:TOKEN" }
# la release del tag: la crea il primo job che arriva, poi si riusa
try {
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
} catch {
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
}
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
+21
View File
@@ -335,3 +335,24 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- Font UI: Inter
- Font numeri: JetBrains Mono
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
## Pacchetto Windows (TieMeasureFlow#76)
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
- ⚠️ 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`.
- ⚠️ `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.
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
+48
View File
@@ -532,6 +532,54 @@ Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.doc
---
## Pacchetto Windows
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
### Installazione
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
### Primo avvio
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
| Componente | Indirizzo |
|---|---|
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
| Worker di visione | http://127.0.0.1:8100/ |
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
Altre informazioni utili:
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
### Come si costruisce
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
```powershell
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
```
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
---
## Documentazione
Indice completo: [`docs/README.md`](docs/README.md).
+26 -2
View File
@@ -59,6 +59,8 @@ COMPILATO = "__compiled__" in globals()
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
def _stream_sicuri() -> None:
@@ -155,9 +157,13 @@ def carica_segreti(dati: Path) -> dict[str, str]:
percorso = dati / "config.env"
valori = _leggi_env(percorso) if percorso.exists() else {}
nuovi = [k for k in SEGRETI if not valori.get(k)]
if nuovi:
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
mancanti = [k for k in PREDEFINITI if k not in valori]
if nuovi or mancanti:
for k in nuovi:
valori[k] = secrets.token_urlsafe(32)
for k in mancanti:
valori[k] = PREDEFINITI[k]
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
righe += [f"{k}={v}" for k, v in valori.items()]
@@ -199,6 +205,10 @@ def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
**{k: valori[k] for k in SEGRETI},
}
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
# ma non sovrascrivono le variabili fisse del contratto
for k, v in valori.items():
env.setdefault(k, v)
programma = cartella_programma()
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
if versione.is_file():
@@ -206,6 +216,12 @@ def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
if gtk.is_dir():
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
if hasattr(os, "add_dll_directory"):
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
try:
os.add_dll_directory(str(gtk))
except OSError:
pass
browser = trova_browser()
if browser:
env["BROWSER_PATH"] = browser
@@ -290,8 +306,16 @@ def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
# ---------------------------------------------------------------- migrazioni
def _neutralizza_fileconfig() -> None:
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
import logging.config
logging.config.fileConfig = lambda *a, **k: None
def esegui_migrazioni() -> None:
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
_neutralizza_fileconfig()
from alembic import command
from alembic.config import Config
import src.backend as pacchetto
@@ -435,7 +459,7 @@ def main(argv=None) -> int:
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
try:
while all(t.is_alive() for t in thread):
while thread and all(t.is_alive() for t in thread):
time.sleep(1)
except KeyboardInterrupt:
pass
+13 -6
View File
@@ -85,18 +85,17 @@ 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=$_" })
# Metadati delle distribuzioni di Core (importlib.metadata): visionsuite, vs-*
$distribuzioni = & $py -c "import importlib.metadata as m; print('\n'.join(sorted({d.metadata['Name'] for d in m.distributions() if d.metadata['Name'].lower() in ('visionsuite',) or d.metadata['Name'].lower().startswith('vs-')})))"
if ($LASTEXITCODE -ne 0) { throw "Elenco delle distribuzioni di Core non riuscito" }
$includi += @($distribuzioni -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-distribution-metadata=$_" })
# 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.
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen...): righe "--include-..." di pdf\LEGGIMI.md
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra.
$flagPdf = @()
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
if (Test-Path $leggimi) {
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
if ($_ -match '^\s*(?:[-*]\s*)?`?(--(?:include|nofollow|noinclude|enable|plugin)[^\s`]*)`?\s*$') { $Matches[1] }
if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] }
})
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
}
@@ -175,6 +174,14 @@ if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre
# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc)
$ini = Join-Path $mig 'alembic.ini'
$testoIni = [System.IO.File]::ReadAllText($ini)
if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" }
$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true"
[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false))
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
+3
View File
@@ -0,0 +1,3 @@
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
gtk/
cache/
+149
View File
@@ -0,0 +1,149 @@
# Report PDF nel pacchetto Windows
Questa cartella contiene quello che serve perché i report PDF di TieMeasureFlow
(`src/backend/services/report_service.py`: Jinja2, Plotly con Kaleido 1.2.0, WeasyPrint 68.1)
funzionino su Windows, dentro il pacchetto, **senza Internet**. Riferimento: TieMeasureFlow#78
(parte del pacchetto Windows, madre #76).
## Come funziona
Un report SPC passa da due componenti nativi che su Windows non arrivano con `pip`:
1. **WeasyPrint** disegna il PDF e carica a runtime le DLL di Pango/GTK
(`libgobject-2.0-0`, `libpango-1.0-0`, `libharfbuzz-0`, `libharfbuzz-subset-0`,
`libfontconfig-1`, `libpangoft2-1.0-0` e le loro dipendenze).
2. **Kaleido 1.x** trasforma i grafici Plotly in SVG pilotando un browser Chromium già installato
(non porta più Chromium con sé). Su ogni Windows 10/11 c'è Edge.
### Le DLL: `prendi-gtk.ps1`
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione <cartella>
```
- Scarica da `repo.msys2.org` (ucrt64) 21 pacchetti **pinnati con sha256** (nome file e hash stanno nello
script), verifica l'hash, estrae solo le 27 DLL necessarie, `etc\fonts` di fontconfig e le licenze.
- Idempotente: se la cartella è già pronta (file `.gtk-pin` uguale ai pin dello script) esce subito.
- Se un hash non coincide fallisce e non lascia mezza cartella.
- I pacchetti scaricati stanno in `%LOCALAPPDATA%\Tielogic\tmf-gtk-cache` (`-Cache` per cambiarla).
- Serve Internet **solo alla build**. Estrae `.zst` con `tar.exe` di Windows (verificato: bsdtar 3.8.9 con
zstd); se `tar.exe` non sa di zstd ripiega su `uv run --with zstandard`.
- Gira da utente non admin con PowerShell 5.1.
- ⚠️ `repo.msys2.org` tiene solo le versioni recenti: se un file pinnato sparisce (404) vanno aggiornati nome
e sha256 dal db `https://repo.msys2.org/mingw/ucrt64/ucrt64.db` e rifatto il test. Conservare la cache
(Wasabi o NAS) evita la corsa contro il repository.
Il piano C copia `<cartella>` in `P\gtk\` (P = cartella programma).
### Variabili d'ambiente (le imposta `avvio.py`, piano C)
| Variabile | Valore | Perché |
|---|---|---|
| `WEASYPRINT_DLL_DIRECTORIES` | `<P>\gtk` | WeasyPrint chiama `os.add_dll_directory` su ogni cartella (separatore `;`) **prima** di caricare le DLL. Va impostata prima di importare `weasyprint` (lo fa `report_service` all'import). |
| `BROWSER_PATH` | `msedge.exe` o `chrome.exe` | Choreographer (sotto Kaleido) la legge da sé e ha la precedenza su ogni ricerca (verificato: un percorso inesistente dà `FileNotFoundError`). Senza la variabile Kaleido cerca Edge/Chrome da solo (registro e percorsi tipici). Nessun codice in `report_service.py` per questo. |
Fontconfig non richiede variabili: trova `etc\fonts` accanto alle DLL (verificato). Se manca `etc\fonts`
WeasyPrint stampa "Fontconfig error: Cannot load default config file". I font dei report vengono da
`C:\Windows\Fonts`.
### MathJax: la modifica a `report_service.py`
Kaleido 1.x inserisce nella pagina di render uno `<script>` MathJax da `cdnjs.cloudflare.com` anche quando i
grafici non hanno formule. Misurato su daitarn con Edge: con rete che scarta i pacchetti ogni grafico aspetta
~19 s (due grafici per report SPC); con rete che rifiuta subito ~2 s. `report_service.py` imposta
`pio.defaults.mathjax` su un file `.js` vuoto in `%TEMP%` (`tmf-mathjax-vuoto.js`): nessuna richiesta di rete,
report SPC in ~7 s anche a rete bloccata. Se un giorno i grafici useranno LaTeX, qui serve un MathJax locale.
`plotly.min.js` invece è già locale (dentro il pacchetto `plotly`).
## Flag Nuitka e dati da includere (per `build.ps1`, piano C)
⚠️ Stato: ricavati leggendo i sorgenti installati (versioni del `uv.lock`); **non provati con una build
Nuitka**: la verifica è la prima build di C. I dati e i metadati sono quelli che il codice legge davvero da disco.
Dati dei pacchetti (file non `.py` letti a runtime):
```text
--include-package-data=weasyprint # css/html5_ua.css, html5_ph.css, html5_ua_form.css, pdf/sRGB2014.icc
--include-package-data=pyphen # dictionaries/*.dic (84 file, sillabazione)
--include-package-data=kaleido # vendor/kaleido_scopes.js (pagina di render)
--include-package-data=plotly # package_data/plotly.min.js (Kaleido lo cerca come file accanto a plotly/__init__), templates/*.json, validators/_validators.json
--include-package-data=tzdata # zoneinfo: Windows non ha il database IANA (vedi sotto)
--include-data-dir=src/backend/templates=src/backend/templates # Jinja2: report_service legge i .html dal disco
```
`choreographer/resources/last_known_good_chrome.json` serve solo al comando che scarica Chrome: non serve.
Metadati delle distribuzioni (`importlib.metadata.version` al momento dell'import): senza, `import plotly` e
Kaleido falliscono con `PackageNotFoundError`:
```text
--include-distribution-metadata=plotly
--include-distribution-metadata=kaleido
```
Import dinamici che l'analisi statica di Nuitka può perdere:
```text
--include-package=fontTools # ttLib/ttFont.py importa le tabelle con __import__("fontTools.ttLib.tables." + tag)
--include-package=plotly # _plotly_utils/importers.py: importlib.import_module lazy dei moduli graph_objs
--include-package=_plotly_utils
--include-module=_cffi_backend # WeasyPrint usa cffi in modalita' ABI (dlopen), serve il modulo C di cffi
--include-package=weasyprint
--include-package=kaleido
--include-package=choreographer
```
Altre note per C:
- WeasyPrint salta `os.add_dll_directory` se `sys.frozen` esiste (`weasyprint/text/ffi.py`). Nuitka di norma
non lo imposta (PyInstaller sì): se in `TieMeasureFlow.exe` le DLL non si trovassero, chiamare
`os.add_dll_directory(<P>\gtk)` da `avvio.py` prima dell'import.
- Le DLL **non** vanno compilate né incluse da Nuitka: restano file in `P\gtk\`.
- Il controllo "nessun `.py`" di `build.ps1` non deve scandire `gtk\` (non ci sono `.py`, ma è meglio
saperlo) né `licenze\`.
- **`tzdata` manca da `pyproject.toml`**: su Windows `ZoneInfo("Europe/Rome")` (usato da
`src/backend/utils/tempo.py`, quindi da ogni report) dà `ZoneInfoNotFoundError`. Va aggiunto
`tzdata; sys_platform == 'win32'` agli extra server e client (o al gruppo `windows`) e il frontend
(`src/frontend/flask_app/app.py` usa `ZoneInfo`) ne ha bisogno allo stesso modo.
- `pdfplumber` è già nell'extra `server` (il test lo usa per leggere i PDF).
## Licenze delle DLL
Le DLL sono binari MSYS2/MinGW (ucrt64) non modificati, collegati dinamicamente. `prendi-gtk.ps1` mette in
`<cartella>\licenze\` i testi di licenza di ogni pacchetto più un `<pacchetto>.txt` con nome del file e licenza.
**L'installer deve includere `gtk\licenze\`.**
| Libreria | Licenza |
|---|---|
| Pango, GLib/GObject/GIO, fribidi, libthai, libdatrie, graphite2, libintl (gettext-runtime), libiconv | LGPL 2.1 o successive |
| FreeType | FTL oppure GPL 2+ (si sceglie FTL) |
| HarfBuzz, expat, libffi, brotli, fontconfig | MIT / permissiva |
| libpng, zlib-ng, bzip2, pcre2 | permissive (libpng, Zlib, bzip2, BSD-3) |
| libgcc, libstdc++ | GPL 3 con GCC Runtime Library Exception (ridistribuzione libera) |
| libwinpthread | MIT e BSD-3 |
Per le librerie LGPL, chi riceve il pacchetto può sostituire le DLL (sono file separati in `gtk\`); i sorgenti
sono pubblici su MSYS2 (`https://packages.msys2.org/`), e l'elenco esatto delle versioni è nello script.
## Test
`src/backend/tests/test_report_windows.py` (saltato fuori da Windows, senza DLL, senza Edge/Chrome o senza
`tzdata`): in un sottoprocesso con WeasyPrint **vero** (il `conftest.py` altrimenti lo sostituisce con un
mock) genera su SQLite in memoria il report SPC (carta di controllo e istogramma) e quello a tabella, e
controlla con `pdfplumber` byte `%PDF-`, almeno una pagina, titoli dei grafici e segni vettoriali.
```powershell
# DLL (una volta) e test
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione packaging\windows\pdf\gtk
uv run --python 3.13 --extra server --extra dev pytest src/backend/tests/test_report_windows.py -q
```
`packaging/windows/pdf/gtk/` e `cache/` sono in `.gitignore`: le DLL non si committano.
### Prova a rete bloccata
`HTTP_PROXY`/`HTTPS_PROXY` non bastano: Edge e Chrome usano il **proxy di sistema** di Windows. Per provare davvero
il PC senza Internet: impostare temporaneamente il proxy di sistema (Impostazioni, Rete, Proxy) su un indirizzo
morto (`127.0.0.1:9` rifiuta subito; `10.255.255.1:8080` scarta i pacchetti, il caso peggiore), generare i
report e **ripristinare** il proxy. Esito del 2026-10-17 con proxy che scarta i pacchetti: report SPC e report a
tabella completi in ~7 s.
+143
View File
@@ -0,0 +1,143 @@
<#
prendi-gtk.ps1 - prepara le DLL di Pango/GTK che WeasyPrint 68.1 vuole su Windows.
COSA FA: scarica da repo.msys2.org (ucrt64) un elenco FISSO di pacchetti, ne verifica lo sha256,
estrae solo le DLL che servono a WeasyPrint (+ etc\fonts di fontconfig e le licenze) in -Destinazione.
COME SI USA: powershell -NoProfile -ExecutionPolicy Bypass -File prendi-gtk.ps1 -Destinazione <cartella>
Opzionali: -Cache <cartella> (pacchetti scaricati, default %LOCALAPPDATA%\Tielogic\tmf-gtk-cache),
-Forza (rifa' anche se la cartella e' gia' pronta).
DA COSA DIPENDE: Windows 10/11 con tar.exe (bsdtar con zstd) oppure uv + Python (modulo zstandard);
Internet solo al momento della build, mai a runtime. PowerShell 5.1 va bene, non serve admin.
IDEMPOTENTE: se la cartella ha gia' le DLL e il file .gtk-pin coincide con questi pin, esce senza scaricare.
FALLISCE se lo sha256 di un pacchetto non coincide (nessun file parziale resta in -Destinazione).
TRAPPOLA: repo.msys2.org tiene solo le versioni recenti. Se un file pinnato sparisce (404) serve aggiornare
i pin qui sotto (nome file + sha256 dal db https://repo.msys2.org/mingw/ucrt64/ucrt64.db) e riprovare i test.
La -Cache conserva i pacchetti gia' scaricati: copiarla su Wasabi/NAS evita il problema.
#>
param(
[Parameter(Mandatory = $true)][string]$Destinazione,
[string]$Cache = (Join-Path $env:LOCALAPPDATA 'Tielogic\tmf-gtk-cache'),
[switch]$Forza
)
$ErrorActionPreference = 'Stop'
# PS 5.1 usa TLS 1.0 di default: repo.msys2.org vuole 1.2 (cosa/perche')
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Base = 'https://repo.msys2.org/mingw/ucrt64/'
# Pin: pacchetto, file, sha256 (dal db ucrt64 di MSYS2 del 2026-10-17), licenza dichiarata.
$Pacchetti = @(
@{ N='brotli'; F='mingw-w64-ucrt-x86_64-brotli-1.2.0-1-any.pkg.tar.zst'; H='9cc89665496ea504751476eafba15c71c554aaf3004babc2004f7c07ffd60514'; L='MIT' }
@{ N='bzip2'; F='mingw-w64-ucrt-x86_64-bzip2-1.0.8-4-any.pkg.tar.zst'; H='f03a2174034ddd2d96cecd34f617c5f8e2ef86c812b8d2bb3b8875257f2c8bfa'; L='bzip2 (BSD)' }
@{ N='expat'; F='mingw-w64-ucrt-x86_64-expat-2.9.0-1-any.pkg.tar.zst'; H='f52364dc874009316e1ee1fe46ee89784594b80d10cbcc1cc856945d575308e0'; L='MIT' }
@{ N='fontconfig'; F='mingw-w64-ucrt-x86_64-fontconfig-2.18.3-1-any.pkg.tar.zst'; H='d0b364f3ee37e3b6f6c136688ce8fa909bb6d8a69df7e08de1aa40870d9e1d60'; L='fontconfig (MIT-like)' }
@{ N='freetype'; F='mingw-w64-ucrt-x86_64-freetype-2.15.0-1-any.pkg.tar.zst'; H='a9a691f71d1a553099c54ca666e611abfb2b4ff08dae24f2a5af72e22a7e5feb'; L='GPL-2.0+ OR FTL' }
@{ N='fribidi'; F='mingw-w64-ucrt-x86_64-fribidi-1.0.17-1-any.pkg.tar.zst'; H='3581ee03cf83a9bb0e98bf688c4f532a29553500e317532d90746de295b0f8a8'; L='LGPL-2.1+' }
@{ N='gettext-runtime'; F='mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1-any.pkg.tar.zst'; H='ba693dda4ac375af76ce481ff3a6e7481286546cc7dc6d56c7021dae34084157'; L='LGPL-2.1+ (libintl)' }
@{ N='glib2'; F='mingw-w64-ucrt-x86_64-glib2-2.90.1-1-any.pkg.tar.zst'; H='9a7826349279bcca0c935b01880d812be9de9609aef2205be68fd7a6128c1695'; L='LGPL-2.1+' }
@{ N='graphite2'; F='mingw-w64-ucrt-x86_64-graphite2-1.3.15-1-any.pkg.tar.zst'; H='34ca1f88bff10ef182a6b1c8e5622d5029f13b05748e361af13b552b422ea849'; L='LGPL-2.1+' }
@{ N='harfbuzz'; F='mingw-w64-ucrt-x86_64-harfbuzz-14.6.0-1-any.pkg.tar.zst'; H='8781d486c864bf91f482768cc5bd7267e49331636583d9f855af3974e6378e69'; L='MIT' }
@{ N='libdatrie'; F='mingw-w64-ucrt-x86_64-libdatrie-0.2.14-1-any.pkg.tar.zst'; H='ea92777801ad325278193a362967540752ae37035016df2f6bcee2d89928a195'; L='LGPL' }
@{ N='libffi'; F='mingw-w64-ucrt-x86_64-libffi-3.8.0-1-any.pkg.tar.zst'; H='99ad12f4ecfa00a889ef9c5c0188592368a5bac109d0c508ce4e04f29ea95a76'; L='MIT' }
@{ N='libgcc'; F='mingw-w64-ucrt-x86_64-libgcc-16.2.0-6-any.pkg.tar.zst'; H='80b2497f019977dd9528b1abc8621b1328a0115ab1f724047ccbb56bf323f11d'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libiconv'; F='mingw-w64-ucrt-x86_64-libiconv-1.19-1-any.pkg.tar.zst'; H='9a500f38c2b91808741c62fae746b3e9110b33a1ecf5c30fa0c66dbedddf7e16'; L='LGPL-2.1+' }
@{ N='libpng'; F='mingw-w64-ucrt-x86_64-libpng-1.6.59-1-any.pkg.tar.zst'; H='7f8448e06eceba8f8ce130390addea49f767bdf8f6b7e7eede8ca6d595e3d3a8'; L='libpng (permissiva)' }
@{ N='libstdc++'; F='mingw-w64-ucrt-x86_64-libstdc++-16.2.0-6-any.pkg.tar.zst'; H='33c5e58a85a538cf843a8672991cce591f0231d66da3b66a57629712d64be17f'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libthai'; F='mingw-w64-ucrt-x86_64-libthai-0.1.30-1-any.pkg.tar.zst'; H='c90ebbca3e7b4c34f4b3ffa501ba8d00cdaf6340cff7db3c46d79d5e01ef207c'; L='LGPL-2.1+' }
@{ N='libwinpthread'; F='mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r426.g4564ee4b5-2-any.pkg.tar.zst'; H='a02f5e09d666d3be657c78c86fe67c975d04fe33b5211cd5ff1b23a91c118524'; L='MIT AND BSD-3-Clause' }
@{ N='pcre2'; F='mingw-w64-ucrt-x86_64-pcre2-10.49-1-any.pkg.tar.zst'; H='63172179db724ac71e2439a7981134c1bcab8049bdddfd349b32b8f76ed52d1e'; L='BSD-3-Clause' }
@{ N='pango'; F='mingw-w64-ucrt-x86_64-pango-1.58.2-1-any.pkg.tar.zst'; H='8d86e4a018ff139b010bf357fa1c9f9030fa32ee195c54ed7e89d8ea701851c8'; L='LGPL-2.1' }
@{ N='zlib-ng-compat'; F='mingw-w64-ucrt-x86_64-zlib-ng-compat-2.3.3-2-any.pkg.tar.zst'; H='b3ca6b437fb154a0fa26652002cdda591498ee4a0e08d91cc653c3702d090f25'; L='Zlib' }
)
# DLL che WeasyPrint carica (6 radici) piu' la loro chiusura di dipendenze (calcolata con pefile il 2026-10-17).
$Dll = @(
'libbrotlicommon.dll','libbrotlidec.dll','libbz2-1.dll','libdatrie-1.dll','libexpat-1.dll','libffi-8.dll',
'libfontconfig-1.dll','libfreetype-6.dll','libfribidi-0.dll','libgcc_s_seh-1.dll','libgio-2.0-0.dll',
'libglib-2.0-0.dll','libgmodule-2.0-0.dll','libgobject-2.0-0.dll','libgraphite2.dll','libharfbuzz-0.dll',
'libharfbuzz-subset-0.dll','libiconv-2.dll','libintl-8.dll','libpango-1.0-0.dll','libpangoft2-1.0-0.dll',
'libpcre2-8-0.dll','libpng16-16.dll','libstdc++-6.dll','libthai-0.dll','libwinpthread-1.dll','zlib1.dll'
)
function Scrivi([string]$m) { Write-Host ("[prendi-gtk] " + $m) }
# Impronta dei pin: se cambia, la cartella gia' pronta non e' piu' valida.
$pinTesto = (($Pacchetti | ForEach-Object { $_.F + ':' + $_.H }) -join "`n") + "`n" + ($Dll -join ',')
$sha = [Security.Cryptography.SHA256]::Create()
$impronta = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($pinTesto)) | ForEach-Object { $_.ToString('x2') })
$marker = Join-Path $Destinazione '.gtk-pin'
$dllMancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $Destinazione $_)) })
if (-not $Forza -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $impronta) -and $dllMancanti.Count -eq 0) {
Scrivi "gia' pronto in $Destinazione (pin $($impronta.Substring(0,12)))"
exit 0
}
# --- estrazione .zst: tar.exe di Windows se sa di zstd, altrimenti Python zstandard via uv ---
$tar = Join-Path $env:SystemRoot 'System32\tar.exe'
$tarZstd = $false
if (Test-Path $tar) { $tarZstd = ((& $tar --version) -join ' ') -match 'zstd' }
function Estrai([string]$pkg, [string]$dest) {
if ($tarZstd) {
& $tar -xf $pkg -C $dest
if ($LASTEXITCODE -ne 0) { throw "tar.exe ha fallito su $pkg" }
} else {
$py = "import sys,tarfile,zstandard`nd=zstandard.ZstdDecompressor()`nwith open(sys.argv[1],'rb') as f, d.stream_reader(f) as r:`n tarfile.open(fileobj=r,mode='r|').extractall(sys.argv[2])"
$tmpPy = Join-Path ([IO.Path]::GetTempPath()) ('estrai-zst-' + [guid]::NewGuid().ToString('N') + '.py')
Set-Content -Path $tmpPy -Value $py -Encoding ASCII
try {
& uv run --quiet --with zstandard python -I $tmpPy $pkg $dest
if ($LASTEXITCODE -ne 0) { throw "estrazione zstd via uv fallita su $pkg" }
}
finally { Remove-Item $tmpPy -ErrorAction SilentlyContinue }
}
}
New-Item -ItemType Directory -Force -Path $Cache | Out-Null
# Lavoro in una cartella temporanea, poi sposto: un errore a meta' non lascia mezza cartella in -Destinazione.
$tmp = Join-Path ([IO.Path]::GetTempPath()) ('tmf-gtk-' + [guid]::NewGuid().ToString('N'))
$stage = Join-Path $tmp 'stage'
New-Item -ItemType Directory -Force -Path $stage | Out-Null
try {
foreach ($p in $Pacchetti) {
$file = Join-Path $Cache $p.F
$ok = $false
if (Test-Path $file) {
$ok = ((Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -eq $p.H)
if (-not $ok) { Scrivi "cache corrotta, riscarico $($p.F)"; Remove-Item $file -Force }
}
if (-not $ok) {
Scrivi "scarico $($p.F)"
$parz = $file + '.parz'
# il '+' di libstdc++ va codificato nell'URL
Invoke-WebRequest -UseBasicParsing -Uri ($Base + [Uri]::EscapeDataString($p.F)) -OutFile $parz
$h = (Get-FileHash $parz -Algorithm SHA256).Hash.ToLower()
if ($h -ne $p.H) { Remove-Item $parz -Force; throw "sha256 diverso per $($p.F): atteso $($p.H), trovato $h" }
Move-Item $parz $file -Force
}
$ex = Join-Path $tmp $p.N
New-Item -ItemType Directory -Force -Path $ex | Out-Null
Estrai $file $ex
$bin = Join-Path $ex 'ucrt64\bin'
if (Test-Path $bin) { Get-ChildItem $bin -Filter *.dll | Copy-Item -Destination $stage -Force }
# fontconfig: senza etc\fonts WeasyPrint non trova i font (errore "Cannot load default config file")
if ($p.N -eq 'fontconfig') { Copy-Item (Join-Path $ex 'ucrt64\etc\fonts') (Join-Path $stage 'etc\fonts') -Recurse -Force }
# licenze: le DLL LGPL si ridistribuiscono con il testo della licenza
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'licenze') | Out-Null
$lic = Join-Path $ex 'ucrt64\share\licenses'
if (Test-Path $lic) { Copy-Item $lic (Join-Path $stage ('licenze\' + $p.N)) -Recurse -Force }
Set-Content -Path (Join-Path $stage ('licenze\' + $p.N + '.txt')) -Value ($p.F + "`nlicenza: " + $p.L + "`nsorgente: https://packages.msys2.org/") -Encoding UTF8
}
# Tengo solo le DLL in whitelist: le altre DLL dei pacchetti non servono.
Get-ChildItem $stage -Filter *.dll | Where-Object { $Dll -notcontains $_.Name } | Remove-Item -Force
$mancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $stage $_)) })
if ($mancanti.Count -gt 0) { throw "DLL attese non trovate: $($mancanti -join ', ')" }
Set-Content -Path (Join-Path $stage '.gtk-pin') -Value $impronta -Encoding ASCII
if (Test-Path $Destinazione) { Get-ChildItem $Destinazione -Force | Remove-Item -Recurse -Force }
New-Item -ItemType Directory -Force -Path $Destinazione | Out-Null
Get-ChildItem $stage -Force | Move-Item -Destination $Destinazione -Force
Scrivi "pronto: $($Dll.Count) DLL in $Destinazione"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+197
View File
@@ -0,0 +1,197 @@
"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova
del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti).
Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license):
UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \
--extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q
"""
from __future__ import annotations
import base64
import functools
import importlib.util
import json
import os
import sqlite3
from datetime import date, timedelta
from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
RADICE = Path(__file__).resolve().parents[3]
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
IMPRONTA = "ab" * 32
def _firma(dati: dict) -> str:
# stessa canonicalizzazione del contratto (byte firmati)
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
@pytest.fixture
def avvio(monkeypatch, tmp_path):
# avvio.py non e' in un pacchetto: lo si carica dal percorso
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
verifica_reale = m.lic.verifica
monkeypatch.setattr(m.lic, "verifica", functools.partial(
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
m.chiamate = []
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
m.aperti = []
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
return m
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
dati.update(modifiche)
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
return p
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
assert avvio.main(["--senza-finestre"]) == 1
assert avvio.chiamate == [] and avvio.aperti == []
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
err = capsys.readouterr().err
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
@pytest.mark.parametrize("modifica,testo", [
({"scadenza": "2020-01-01"}, "scaduta"),
({"impronta": "cd" * 32}, "altra macchina"),
({"prodotto": "tievision-studio"}, "altro prodotto"),
])
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
_scrivi_licenza(tmp_path, **modifica)
assert avvio.main(["--senza-finestre"]) == 1
assert avvio.chiamate == []
assert testo in capsys.readouterr().err
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
p = _scrivi_licenza(tmp_path)
doc = json.loads(p.read_text(encoding="utf-8"))
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
p.write_text(json.dumps(doc), encoding="utf-8")
assert avvio.main(["--senza-finestre"]) == 1
assert "Firma" in capsys.readouterr().err
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
_scrivi_licenza(tmp_path)
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
# prima le migrazioni, poi i server
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
assert avvio.aperti == []
def test_variabili_del_contratto(avvio, tmp_path):
_scrivi_licenza(tmp_path)
avvio.main(["--senza-finestre", "--senza-browser"])
e = os.environ
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert e["APP_ENV"] == "production"
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
assert e["SESSION_COOKIE_SECURE"] == "0"
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
assert len(e[k]) >= 32
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
primo = avvio.carica_segreti(dati)
testo = (dati / "config.env").read_text(encoding="utf-8")
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
assert primo["STATION_CODE"] == "ST-DEFAULT"
secondo = avvio.carica_segreti(dati)
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
dati.mkdir(parents=True)
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
v = avvio.carica_segreti(dati)
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
assert avvio.carica_segreti(dati) == v
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
assert avvio.main(["--senza-finestre"]) == 0
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
assert avvio.main(["--senza-finestre"]) == 2
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
_scrivi_licenza(tmp_path)
def rotta():
raise RuntimeError("db bloccato")
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
assert avvio.main(["--senza-finestre"]) == 3
assert "db bloccato" in capsys.readouterr().err
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
assert IMPRONTA in capsys.readouterr().out
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
db = tmp_path / "t.db"
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
con = sqlite3.connect(db)
con.execute("CREATE TABLE users (id INTEGER)")
con.commit()
assert avvio._nessun_utente(db) is True
con.execute("INSERT INTO users VALUES (1)")
con.commit()
con.close()
assert avvio._nessun_utente(db) is False
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
_scrivi_licenza(tmp_path)
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
dati.mkdir(parents=True)
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
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
+1
View File
@@ -85,6 +85,7 @@ vision-worker = [
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
[dependency-groups]
windows = [
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
+7 -2
View File
@@ -3,6 +3,7 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access).
"""
import asyncio
import hmac
import random
from datetime import datetime, timedelta
@@ -312,7 +313,9 @@ async def init_db(body: PasswordBody):
await _require_not_initialised()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database tables created successfully"}
@@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database reset successfully"}
+17 -2
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
db_name: str = "tiemeasureflow"
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
db_url: str | None = None
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Async MySQL connection string."""
"""Async connection string: DB_URL if set, else the MySQL one."""
if self.db_url:
return self.db_url
return (
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
(mounted as a Docker volume), not inside the backend tree.
"""
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
# in that case it is used as is instead of being joined to the root.
configured = Path(self.upload_dir)
if configured.is_absolute():
return configured
return Path(__file__).resolve().parents[2] / configured
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
# A SQLite file has no DB password: the default-password guard only
# makes sense for the MySQL connection built from the DB_* parts.
if self.db_url and self.db_url.startswith("sqlite"):
return self
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
+25 -8
View File
@@ -1,6 +1,7 @@
"""Async SQLAlchemy database engine and session management."""
from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
from src.backend.config import settings
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
_IS_SQLITE = settings.database_url.startswith("sqlite")
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
if _IS_SQLITE:
engine = create_async_engine(settings.database_url, echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
busy_timeout let the web, vision and backend threads share the file."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
else:
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
# Session factory
async_session_factory = async_sessionmaker(
+18
View File
@@ -1,6 +1,7 @@
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import asyncio
import base64
import tempfile
from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
compute_summary,
)
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
def _mathjax_vuoto() -> str:
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
if not path.exists():
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
return str(path)
try:
pio.defaults.mathjax = _mathjax_vuoto()
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
pass
# Jinja2 environment for report templates
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
_jinja_env = Environment(
+164
View File
@@ -0,0 +1,164 @@
"""Report PDF veri su Windows (WeasyPrint + DLL GTK + Kaleido con Edge/Chrome), offline.
Pacchetto Windows (TieMeasureFlow#78): senza Internet i report devono uscire lo stesso.
Il test gira solo su Windows, con le DLL di packaging/windows/pdf/prendi-gtk.ps1 e un browser
Chromium (Edge o Chrome) installati; altrimenti viene saltato.
Il conftest dei test sostituisce WeasyPrint con un mock: qui serve quello vero, quindi la
generazione gira in un sottoprocesso che importa weasyprint PRIMA del conftest (il mock scatta
solo se weasyprint non e' gia' in sys.modules).
Come preparare le DLL: powershell -File packaging/windows/pdf/prendi-gtk.ps1 -Destinazione packaging/windows/pdf/gtk
(oppure WEASYPRINT_DLL_DIRECTORIES gia' impostata su una cartella con le DLL).
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
GTK_DEFAULT = REPO / "packaging" / "windows" / "pdf" / "gtk"
# Browser Chromium cercati in ordine (Edge e' presente su ogni Windows 10/11).
_BROWSER_CANDIDATI = [
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]
def _cartella_gtk() -> Path | None:
"""Cartella DLL: WEASYPRINT_DLL_DIRECTORIES (primo elemento) o quella di prendi-gtk.ps1."""
env = os.environ.get("WEASYPRINT_DLL_DIRECTORIES", "").split(";")[0]
for c in (Path(env) if env else None, GTK_DEFAULT):
if c and (c / "libpango-1.0-0.dll").exists():
return c
return None
def _browser() -> str | None:
env = os.environ.get("BROWSER_PATH")
if env and Path(env).exists():
return env
return next((b for b in _BROWSER_CANDIDATI if Path(b).exists()), None)
def _tz_ok() -> bool:
"""Windows non ha il database IANA: serve il pacchetto tzdata (to_local usa ZoneInfo)."""
try:
from zoneinfo import ZoneInfo
ZoneInfo("Europe/Rome")
return True
except Exception:
return False
pytestmark = [
pytest.mark.skipif(sys.platform != "win32", reason="solo Windows"),
pytest.mark.skipif(_cartella_gtk() is None, reason="DLL GTK assenti: lancia prendi-gtk.ps1"),
pytest.mark.skipif(_browser() is None, reason="nessun Edge/Chrome installato"),
# Senza tzdata i report falliscono su Windows (ZoneInfoNotFoundError): il pacchetto deve dichiararlo.
pytest.mark.skipif(not _tz_ok(), reason="manca tzdata: aggiungere 'tzdata; sys_platform == \"win32\"' a pyproject"),
]
# Sottoprocesso: dati veri nel DB SQLite in memoria del conftest, poi i due report reali.
_WORKER = r"""
import asyncio, json, sys
sys.path.insert(0, sys.argv[1])
import weasyprint # VERO, prima del conftest (che altrimenti lo sostituisce con un mock)
assert not type(weasyprint.HTML).__module__.startswith("unittest"), "WeasyPrint mockato"
from datetime import datetime, timedelta
from src.backend.tests.conftest import Base, TestSessionFactory, test_engine, _create_user, create_test_recipe
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask
from src.backend.services import report_service
from sqlalchemy import select
VALORI = [10.02, 9.98, 10.05, 10.11, 9.94, 10.0, 10.07, 9.96, 10.03, 10.09, 9.99, 10.04, 10.38, 10.01, 9.97]
async def main():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionFactory() as db:
user = await _create_user(db, "metrologo", roles=["Metrologist"])
recipe = await create_test_recipe(db, user.id)
version = recipe.versions[0]
sub = (await db.execute(select(RecipeSubtask))).scalars().first()
t0 = datetime.utcnow() - timedelta(hours=1)
for i, v in enumerate(VALORI):
pf = "warning" if v > 10.3 else "pass"
db.add(Measurement(subtask_id=sub.id, version_id=version.id, measured_by=user.id,
value=v, pass_fail=pf, measured_at=t0 + timedelta(minutes=i)))
await db.commit()
spc = await report_service.generate_spc_report(db, recipe.id, sub.id)
mis = await report_service.generate_measurement_report(db, recipe.id)
open(sys.argv[2] + "/spc.pdf", "wb").write(spc)
open(sys.argv[2] + "/misure.pdf", "wb").write(mis)
# MathJax locale: senza, Kaleido chiede lo script al CDN e senza Internet ogni grafico attende ~19 s.
from pathlib import Path
mj = report_service.pio.defaults.mathjax
print(json.dumps({"ok": True, "mathjax_locale": bool(mj) and Path(mj).is_file()}))
asyncio.run(main())
"""
@pytest.fixture(scope="module")
def pdf_generati(tmp_path_factory):
"""Genera i due PDF in un sottoprocesso con proxy finto: se serve Internet, fallisce."""
out = tmp_path_factory.mktemp("report_win")
env = dict(os.environ)
env.update(
WEASYPRINT_DLL_DIRECTORIES=str(_cartella_gtk()),
BROWSER_PATH=_browser(),
# Proxy morto per la parte Python (Chrome/Edge usano invece il proxy di sistema: vedi LEGGIMI.md).
HTTP_PROXY="http://127.0.0.1:9", HTTPS_PROXY="http://127.0.0.1:9",
PYTHONIOENCODING="utf-8",
)
# Il sottoprocesso non deve leggere il .env di sviluppo ne' un DB reale.
env.pop("DB_URL", None)
res = subprocess.run(
[sys.executable, "-W", "ignore", "-c", _WORKER, str(REPO), str(out)],
cwd=REPO, env=env, capture_output=True, text=True, timeout=180,
)
assert res.returncode == 0, f"sottoprocesso fallito:\n{res.stdout}\n{res.stderr}"
esito = json.loads(res.stdout.strip().splitlines()[-1])
assert esito["ok"] is True
assert esito["mathjax_locale"], "Kaleido userebbe MathJax dal CDN: niente rete = report lenti"
return out
def _pdf(path: Path):
import pdfplumber
return pdfplumber.open(path)
def test_spc_ha_carta_di_controllo_e_istogramma(pdf_generati):
"""Il report SPC e' un PDF vero con una pagina almeno e i due grafici disegnati."""
path = pdf_generati / "spc.pdf"
assert path.read_bytes()[:5] == b"%PDF-"
with _pdf(path) as pdf:
assert len(pdf.pages) >= 1
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
# I titoli stanno dentro l'SVG di Plotly: se ci sono, Kaleido ha reso e WeasyPrint ha disegnato.
assert "Control Chart" in testo
assert "Histogram" in testo
# Grafico presente = segni vettoriali (tracce, assi, barre) oltre al testo.
segni = sum(len(p.curves) + len(p.lines) + len(p.rects) for p in pdf.pages)
assert segni > 40, f"troppo pochi segni vettoriali ({segni}): grafico assente?"
def test_report_misure_e_pdf_vero(pdf_generati):
"""Anche il report a tabella (solo WeasyPrint, niente Kaleido) esce con le righe misurate."""
path = pdf_generati / "misure.pdf"
assert path.read_bytes()[:5] == b"%PDF-"
with _pdf(path) as pdf:
assert len(pdf.pages) >= 1
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
assert "10.3800" in testo # il valore anomalo e' in tabella
+81
View File
@@ -0,0 +1,81 @@
"""init-db / reset-db con _stamp_head REALE su un file SQLite (TieMeasureFlow#80).
_stamp_head esegue migrations/env.py, che chiama asyncio.run(): dentro il loop
gia' attivo di FastAPI dava RuntimeError -> 500. test_setup.py e
test_station_seed.py sostituiscono _stamp_head con un no-op e non lo vedevano:
qui NON si fa monkeypatch di _stamp_head.
"""
import logging.config
import sqlite3
import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.backend.config import settings
from src.backend.main import app
SETUP_PWD = "test-setup-password"
@pytest.fixture
def file_db(tmp_path, monkeypatch):
"""Punta settings, engine e session factory del router setup a un file SQLite."""
import src.backend.api.routers.setup as setup_mod
db_file = tmp_path / "t.db"
url = f"sqlite+aiosqlite:///{db_file.as_posix()}"
# env.py ricava sqlalchemy.url da settings.database_url (property su db_url)
monkeypatch.setattr(settings, "db_url", url)
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
file_engine = create_async_engine(url, echo=False)
monkeypatch.setattr(setup_mod, "engine", file_engine)
monkeypatch.setattr(
setup_mod, "async_session_factory",
async_sessionmaker(file_engine, expire_on_commit=False),
)
# env.py chiama fileConfig, che disattiverebbe i logger degli altri test
monkeypatch.setattr(logging.config, "fileConfig", lambda *a, **k: None)
return db_file, setup_mod, file_engine
def _head() -> str:
from src.backend.api.routers.setup import _alembic_ini
cfg = Config(str(_alembic_ini))
return ScriptDirectory.from_config(cfg).get_current_head()
def _versions(db_file) -> list[str]:
con = sqlite3.connect(db_file)
try:
return [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
finally:
con.close()
@pytest.fixture
async def http():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac:
yield ac
@pytest.mark.asyncio
async def test_init_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
@pytest.mark.asyncio
async def test_reset_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
resp = await http.post("/api/setup/reset-db", json={"password": SETUP_PWD, "confirm": True})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
+144
View File
@@ -0,0 +1,144 @@
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
l'engine in memoria usato dal resto della suite.
"""
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from src.backend.config import Settings
_ROOT = Path(__file__).resolve().parents[3]
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
_SCRIPT = textwrap.dedent(
"""
import asyncio, json, sys
from pathlib import Path
from unittest.mock import MagicMock
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
sys.modules.setdefault("weasyprint", MagicMock())
from alembic import command
from alembic.config import Config
from src.backend.config import settings
ini = Path(r"{ini}")
command.upgrade(Config(str(ini)), "head")
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from src.backend.database import async_session_factory, engine
from src.backend.main import app
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.services.auth_service import hash_password
async def main():
out = {{}}
async with async_session_factory() as s:
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
display_name="Fiera", roles=["Maker"], is_admin=True))
await s.commit()
async with async_session_factory() as s:
out["utente"] = (await s.execute(select(User.username))).scalars().all()
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
try:
async with async_session_factory() as s:
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
await s.commit()
out["fk_violation"] = False
except IntegrityError:
out["fk_violation"] = True
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
out["health"] = (await c.get("/api/health")).status_code
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
out["login"] = r.status_code
out["upload_path"] = str(settings.upload_path)
out["database_url"] = settings.database_url
await engine.dispose()
print("RISULTATO=" + json.dumps(out))
asyncio.run(main())
"""
)
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
env.update(extra_env)
env["PYTHONPATH"] = str(_ROOT)
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
)
assert proc.returncode == 0, proc.stderr[-3000:]
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
return json.loads(line[len("RISULTATO="):])
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
db = tmp_path / "tmf.db"
uploads = tmp_path / "uploads"
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
url = f"sqlite+aiosqlite:///{db.as_posix()}"
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
assert out["database_url"] == url
assert db.exists() # il file e' stato creato da upgrade head
assert out["utente"] == ["fiera"]
assert out["health"] == 200
assert out["login"] == 200
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
assert out["fk_violation"] is True
assert out["journal"] == "wal"
assert out["busy"] == 5000
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
assert Path(out["upload_path"]) == uploads
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
monkeypatch.delenv("DB_URL", raising=False)
s = Settings(_env_file=None, db_url=None)
assert s.database_url.startswith("mysql+asyncmy://")
def test_db_url_vince_sulle_parti_db(monkeypatch):
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
def test_produzione_con_sqlite_non_richiede_db_password():
# niente RuntimeError: con SQLite la password DB di default non conta
Settings(
_env_file=None, app_env="production", server_secret_key="x" * 40,
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
)
def test_produzione_mysql_rifiuta_ancora_password_di_default():
import pytest
with pytest.raises(Exception, match="DB_PASSWORD"):
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
def test_upload_dir_relativo_resta_sotto_la_radice():
s = Settings(_env_file=None, upload_dir="uploads")
assert s.upload_path == _ROOT / "uploads"
def test_upload_dir_assoluto(tmp_path):
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
assert s.upload_path == tmp_path / "u"
Generated
+11
View File
@@ -2103,6 +2103,7 @@ windows = [
{ name = "aiosqlite" },
{ name = "nuitka" },
{ name = "ordered-set" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "vs-camera", extra = ["genicam"], marker = "python_full_version >= '3.13'" },
{ name = "vs-license", marker = "python_full_version >= '3.13'" },
{ name = "waitress" },
@@ -2158,6 +2159,7 @@ windows = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "nuitka", specifier = ">=2.5" },
{ name = "ordered-set", specifier = ">=4.1" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "vs-camera", extras = ["genicam"], marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-camera" },
{ name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" },
{ name = "waitress", specifier = ">=3.0.0" },
@@ -2209,6 +2211,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"