Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aac8cc490 | |||
| 8b3e897668 | |||
| eea3e319fb | |||
| bfc7c391e8 | |||
| 8d4c4020f6 | |||
| 959e92552b |
@@ -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" }
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user