Merge piano C: setup Windows di TieMeasureFlow (TieMeasureFlow#76)
rilascio-windows / setup-windows (push) Successful in 32m46s

This commit is contained in:
2026-10-17 12:39:35 +02:00
10 changed files with 1382 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+81
View File
@@ -0,0 +1,81 @@
; Installer di TieMeasureFlow (TieMeasureFlow#76).
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
; e i collegamenti in menu Start e sul desktop.
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
; uploads e config.env: sono dati del cliente.
#ifndef Versione
#define Versione "0.0.0"
#endif
#ifndef Versione4
#define Versione4 "0.0.0.0"
#endif
#ifndef Origine
#define Origine "..\..\build\nuitka\avvio.dist"
#endif
#ifndef Uscita
#define Uscita "..\..\dist"
#endif
[Setup]
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
AppName=TieMeasureFlow
AppVersion={#Versione}
AppVerName=TieMeasureFlow {#Versione}
AppPublisher=Tielogic
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
DefaultGroupName=Tielogic\TieMeasureFlow
PrivilegesRequired=admin
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
PrivilegesRequiredOverridesAllowed=commandline
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#Uscita}
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
SetupIconFile=TieMeasureFlow.ico
UninstallDisplayIcon={app}\TieMeasureFlow.exe
VersionInfoVersion={#Versione4}
VersionInfoCompany=Tielogic
VersionInfoProductName=TieMeasureFlow
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
DisableProgramGroupPage=yes
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
CloseApplications=yes
[Languages]
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
[Tasks]
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
[Dirs]
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
[Files]
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
; l'exe non ha finestra: per fermarlo si chiude il processo
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
+477
View File
@@ -0,0 +1,477 @@
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
motivo e l'impronta del computer, scrive l'impronta in
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
quattro server, ciascuno nel suo thread:
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
backend FastAPI src.backend.main:app, 127.0.0.1:8000
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
"""
from __future__ import annotations
import argparse
import ctypes
import json
import logging
import os
import secrets
import socket
import sqlite3
import sys
import threading
import time
import urllib.request
import webbrowser
from pathlib import Path
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
PRODOTTO = "tiemeasureflow"
TITOLO = "TieMeasureFlow"
PORTA_WEB = 8780
PORTA_BACKEND = 8000
PORTA_VISION = 8100
PORTA_CAMERA = 8765
log = logging.getLogger("tmf.avvio")
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
COMPILATO = "__compiled__" in globals()
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
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:
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
# andrebbero in errore; li si sostituisce con il device nullo
if sys.stdout is None:
sys.stdout = open(os.devnull, "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w", encoding="utf-8")
def cartella_tielogic() -> Path:
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
return Path(base) / "Tielogic"
def cartella_dati() -> Path:
return cartella_tielogic() / "TieMeasureFlow"
def cartella_programma() -> Path:
# Cartella dell'exe (P); da sorgente, la radice del repo
if COMPILATO:
return Path(sys.argv[0]).resolve().parent
return RADICE_SORGENTI
def percorso_impronta() -> Path:
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
if sys.stderr is not None:
print(testo, file=sys.stderr)
if senza_finestre or sys.platform != "win32":
return
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
# ---------------------------------------------------------------- licenza
def _scrivi_impronta(impronta: str) -> None:
try:
p = percorso_impronta()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(impronta + "\n", encoding="utf-8")
except OSError as e:
log.warning("impronta non scritta: %s", e)
def controlla_licenza(senza_finestre: bool = False) -> bool:
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
try:
percorso = lic.cerca_licenza(PRODOTTO)
lic.verifica(percorso, PRODOTTO)
return True
except lic.LicenzaNonValida as e:
motivo = str(e)
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
motivo = f"Controllo licenza non riuscito: {e}"
try:
impronta = lic.impronta()
except Exception as e:
impronta = None
motivo += f"\nImpronta macchina non disponibile: {e}"
testo = f"{motivo}\n\n"
if impronta:
_scrivi_impronta(impronta)
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
return False
# ---------------------------------------------------------------- configurazione
def _leggi_env(percorso: Path) -> dict[str, str]:
valori: dict[str, str] = {}
for riga in percorso.read_text(encoding="utf-8").splitlines():
riga = riga.strip()
if riga and not riga.startswith("#") and "=" in riga:
k, v = riga.split("=", 1)
valori[k.strip()] = v.strip()
return valori
def carica_segreti(dati: Path) -> dict[str, str]:
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
dati.mkdir(parents=True, exist_ok=True)
percorso = dati / "config.env"
valori = _leggi_env(percorso) if percorso.exists() else {}
nuovi = [k for k in SEGRETI if not valori.get(k)]
# 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()]
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
log.info("segreti generati in %s", percorso)
return valori
def trova_browser() -> str | None:
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA", "")
candidati = [
Path(pf) / "Google/Chrome/Application/chrome.exe",
Path(pf86) / "Google/Chrome/Application/chrome.exe",
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
]
for c in candidati:
if c is not None and c.is_file():
return str(c)
return None
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
uploads = dati / "uploads"
env = {
"APP_ENV": "production",
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
"UPLOAD_DIR": str(uploads),
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
"VISION_RECIPES_ROOT": str(uploads),
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
"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():
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
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
os.environ.update(env)
for cartella in (uploads, dati / "camera", dati / "log"):
cartella.mkdir(parents=True, exist_ok=True)
def _prepara_percorsi_sorgenti() -> None:
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
if COMPILATO:
return
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
# ---------------------------------------------------------------- log
def _prepara_log(dati: Path | None = None) -> None:
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
try:
d = (dati or cartella_dati()) / "log"
d.mkdir(parents=True, exist_ok=True)
percorso = str(d / "tiemeasureflow.log")
root = logging.getLogger()
for h in root.handlers:
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
break
else:
h = logging.FileHandler(percorso, encoding="utf-8")
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root.addHandler(h)
root.setLevel(logging.INFO)
for nome in ("tmf.avvio",):
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
except OSError:
pass
# ---------------------------------------------------------------- rete
def _porta_aperta(porta: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
return True
except OSError:
return False
def _e_tmf() -> bool:
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
except Exception:
return False
def _nessun_utente(db: Path) -> bool:
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
try:
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
try:
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
finally:
con.close()
except Exception:
return False
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
for _ in range(300):
if all(_porta_aperta(p) for p in porte):
break
time.sleep(0.2)
webbrowser.open(url)
# ---------------------------------------------------------------- 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
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
cfg = Config(str(migrazioni / "alembic.ini"))
if COMPILATO:
cfg.set_main_option("sourceless", "true")
command.upgrade(cfg, "head")
# ---------------------------------------------------------------- server
class ServerThread(threading.Thread):
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
def __init__(self, nome: str, costruisci):
super().__init__(name=nome, daemon=True)
self._costruisci = costruisci
self.server = None
self.errore: BaseException | None = None
def run(self) -> None:
try:
self.server = self._costruisci()
self.server.run()
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
self.errore = e
log.exception("server %s terminato con errore", self.name)
def ferma(self) -> None:
s = self.server
try:
if hasattr(s, "should_exit"):
s.should_exit = True # uvicorn
elif s is not None:
s.close() # waitress
except Exception:
pass
def _uvicorn(app, porta: int):
import uvicorn
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
def costruisci_web(host: str):
import waitress
from app import create_app # src/frontend/flask_app/app.py
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
def costruisci_backend():
from src.backend.main import app
return _uvicorn(app, PORTA_BACKEND)
def costruisci_vision():
from src.vision_worker.main import app
return _uvicorn(app, PORTA_VISION)
def costruisci_camera(cartella: Path):
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
return _uvicorn(create_app(cartella), PORTA_CAMERA)
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
thread = [
ServerThread("backend", costruisci_backend),
ServerThread("vision", costruisci_vision),
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
ServerThread("web", lambda: costruisci_web(host)),
]
for t in thread:
t.start()
return thread
def main(argv=None) -> int:
_stream_sicuri()
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
args = p.parse_args(argv)
_prepara_log()
if args.impronta:
try:
impronta = lic.impronta()
except Exception as e:
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
return 1
print(impronta) if sys.stdout is not None else None
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
return 0
if not controlla_licenza(args.senza_finestre):
return 1
if _porta_aperta(PORTA_WEB):
if _e_tmf():
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
if not args.senza_browser:
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
return 0
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
senza_finestre=args.senza_finestre)
return 2
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
if occupate:
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
senza_finestre=args.senza_finestre)
return 2
dati = cartella_dati()
try:
valori = carica_segreti(dati)
imposta_ambiente(dati, args.host, valori)
_prepara_percorsi_sorgenti()
primo_avvio = not (dati / "tmf.db").exists()
esegui_migrazioni()
except Exception as e:
_prepara_log(dati)
log.exception("avvio non riuscito")
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
senza_finestre=args.senza_finestre)
return 3
_prepara_log(dati) # alembic ha riconfigurato il logging
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
thread = avvia_server(args.host, dati)
if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
else f"http://127.0.0.1:{PORTA_WEB}/")
threading.Thread(target=_apri_browser_quando_pronto,
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
try:
while thread and all(t.is_alive() for t in thread):
time.sleep(1)
except KeyboardInterrupt:
pass
morti = [t for t in thread if not t.is_alive()]
for t in thread:
t.ferma()
if morti:
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
return 4
return 0
if __name__ == "__main__":
sys.exit(main())
+212
View File
@@ -0,0 +1,212 @@
<#
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
funziona ma e' piu' lento nel matching.
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
#>
param(
[string]$Versione = $env:GITHUB_REF_NAME,
[switch]$SenzaSetup
)
$ErrorActionPreference = 'Stop'
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $radice
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
if (-not $Versione) { $Versione = '0.0.0' }
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
$ver4 = "$ver3.0"
Write-Host "== TieMeasureFlow $ver3 =="
# Controlli: se manca qualcosa il messaggio dice come installarlo
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
}
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
}
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
}
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
if (-not $iscc) {
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
if (Test-Path $c) { $iscc = $c; break }
}
}
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
$flask = Join-Path $radice 'src\frontend\flask_app'
Push-Location $flask
try {
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
} finally { Pop-Location }
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
# 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*(--(?: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 ' ')"
}
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
$out = Join-Path $radice 'build\nuitka'
New-Item -ItemType Directory -Force $out | Out-Null
$nuitka = @(
'--standalone',
'--assume-yes-for-downloads',
'--msvc=latest',
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
"--output-dir=$out",
'--output-filename=TieMeasureFlow.exe',
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
'--company-name=Tielogic',
'--product-name=TieMeasureFlow',
"--file-version=$ver4",
"--product-version=$ver4",
'--file-description=TieMeasureFlow',
'--copyright=Tielogic',
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
'--include-package=src.backend',
'--include-package=src.vision',
'--include-package=src.vision_worker',
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
'--nofollow-import-to=src.backend.migrations',
'--nofollow-import-to=src.backend.tests',
'--nofollow-import-to=src.vision.tests',
'--nofollow-import-to=src.vision_worker.tests',
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
'--include-module=app',
'--include-module=config',
'--include-package=blueprints',
'--include-package=services',
'--include-module=camera_service',
'--nofollow-import-to=blueprints.tests',
'--nofollow-import-to=tests',
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
'--include-package=waitress',
'--include-package=alembic',
'--include-module=aiosqlite',
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
'--include-package=genicam',
'--include-package-data=genicam',
'--include-package-data=babel', # locale-data di Babel (flask-babel)
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
'--nofollow-import-to=llvmlite',
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
'--include-data-dir=src\backend\templates=src\backend\templates',
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
'--include-data-dir=src\frontend\flask_app\templates=templates',
'--include-data-dir=src\frontend\flask_app\static=static',
'--include-data-dir=src\frontend\flask_app\translations=translations',
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
$t0 = Get-Date
& $py -m nuitka @nuitka
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
$sorgente = Join-Path $out 'avvio.dist'
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
$mig = Join-Path $sorgente 'src\backend\migrations'
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
Copy-Item 'src\backend\migrations\env.py' $mig -Force
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
& $py -m compileall -b -q $mig
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" }
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
if (Test-Path $gtk) {
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
} else {
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
}
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
$dist = Join-Path $radice 'dist'
New-Item -ItemType Directory -Force $dist | Out-Null
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
+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