Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 242add047b |
@@ -1,119 +0,0 @@
|
|||||||
# 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,24 +335,3 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- Font UI: Inter
|
- Font UI: Inter
|
||||||
- Font numeri: JetBrains Mono
|
- Font numeri: JetBrains Mono
|
||||||
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
- 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,54 +532,6 @@ 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
|
## Documentazione
|
||||||
|
|
||||||
Indice completo: [`docs/README.md`](docs/README.md).
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
@@ -1,81 +0,0 @@
|
|||||||
; 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
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<#
|
|
||||||
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))
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -81,20 +81,6 @@ vision-worker = [
|
|||||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
|
|
||||||
# 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)
|
|
||||||
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
|
|
||||||
"nuitka>=2.5", # compilazione standalone
|
|
||||||
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
|
|
||||||
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
# Backend
|
# Backend
|
||||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
@@ -119,8 +105,6 @@ vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true
|
|||||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||||
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
||||||
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
||||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
|
|
||||||
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
+17
-2
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
|||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
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)
|
# Presentation time zone (DB is UTC, #42)
|
||||||
display_timezone: str = "Europe/Rome"
|
display_timezone: str = "Europe/Rome"
|
||||||
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
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 (
|
return (
|
||||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
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.
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
"""
|
"""
|
||||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
# 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
|
# Always resolve .env against the project root regardless of cwd
|
||||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
# (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; "
|
"SERVER_SECRET_KEY is still the committed default value; "
|
||||||
"set it before running with APP_ENV=" + self.app_env
|
"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:
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"DB_PASSWORD is still the committed default value; "
|
"DB_PASSWORD is still the committed default value; "
|
||||||
|
|||||||
+25
-8
@@ -1,6 +1,7 @@
|
|||||||
"""Async SQLAlchemy database engine and session management."""
|
"""Async SQLAlchemy database engine and session management."""
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
|
||||||
echo=False,
|
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
|
||||||
pool_size=10,
|
if _IS_SQLITE:
|
||||||
max_overflow=20,
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
@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
|
# Session factory
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -783,33 +783,6 @@ woff = [
|
|||||||
{ name = "zopfli" },
|
{ name = "zopfli" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "genicam"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/39/97c9a39fde61441731a383bde6a86b079a5a9d1d22c6d9eb16556b77016e/genicam-1.6.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:2a5bfeb3b9796cf6667192c2957f8ea7ec240ff42e76cc6e3e05d1d0230bf68a", size = 2556729, upload-time = "2026-09-01T08:26:57.558Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/1b/bf993221a155d2fd35c8201be97cd0376e005bf1cc82dec27105a712d3b1/genicam-1.6.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:4207d7ccee9deb755ad372c0b473fd2f23e4e42da61e06e4a7873d199b883a0f", size = 2604157, upload-time = "2026-09-01T08:26:58.847Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/92/68c0033f076ad1defed2e6bf1cec8fc720a24622fb0389013b058e841234/genicam-1.6.0-cp311-cp311-manylinux2014_armv7l.whl", hash = "sha256:a750ffb11194f02740531bc0f62bf7013d39f2eea65f5e283c7811ab757fc987", size = 2190252, upload-time = "2026-09-01T08:27:00.126Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/b3/252e5d64022984de4a78220c456f1f91742dbd7bb02d5b3926ed4c228015/genicam-1.6.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:589b1152f22ca766f8b5e1dff9c1b4006c2eb7f5ee33152619ef6ece8a5a3f47", size = 2800290, upload-time = "2026-09-01T08:27:01.364Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/ec/19092034d0f66646d97b85e1549b6b4e2448f5287bb3a76b5a8cd677ba4a/genicam-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef1d25c7e9823689dbfb9e08d8fdb46e0f05982ca9c27d1eeacc1aa8fcd69f6e", size = 1802065, upload-time = "2026-09-01T08:27:02.555Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/84/0dc939a435deef267d82bc6aaa4b7ed9c89510766454640622608d0e7ce5/genicam-1.6.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:d0becabb469d0f51f9d4820d655d2788c620a8ee1c8abcfa4703cac87ba27f53", size = 2557117, upload-time = "2026-09-01T08:27:03.87Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/68/499657bf3e5111fe1c87e3364c2ae6b59fe912d7184fccf591eafd762ceb/genicam-1.6.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:3a1a16b89fec69d52142781f280ce2c01a4c1422da1cd08893ed1cd630d11c30", size = 2607960, upload-time = "2026-09-01T08:27:05.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/ac/3b8496c446715008e77dda7439735c428f163aab0a3f643352407a259a52/genicam-1.6.0-cp312-cp312-manylinux2014_armv7l.whl", hash = "sha256:b65beca4b425442922a2f2beefae1e3109ce166cfd726fcd9d2ac59ff1d57e24", size = 2189851, upload-time = "2026-09-01T08:27:06.516Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/9c/49d68f5055e970e4e4d834bc3df686a01f7bd82e070888fc7ad2c86e2eaf/genicam-1.6.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:83da90404da189a87273864f2c97c761c1807fc6fc532fabd3bf50c0ae28f8ae", size = 2799503, upload-time = "2026-09-01T08:27:07.963Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/4a/e643cac4031ad7b1780ab19bcdeb6b0545e829b2ee10ea37ca1185d9417d/genicam-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d2bb3f39443c5bc248fcbde83a3a973370ca43588a7c1ded7d7324d7d9427d3", size = 1802200, upload-time = "2026-09-01T08:27:09.766Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/f1/b5f641ad64c296462ab2ca314b8a471827142b949606ebe8b69bff7e6a08/genicam-1.6.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f5c35275014f4793b34a21ebe8bbcb269a87b5d1bf129daf2818c6160f8758c9", size = 2557108, upload-time = "2026-09-01T08:27:11.211Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/59/86ae5cbc1a89f70c2b3b906e692a46b977e9230664781cd5a304012f6b20/genicam-1.6.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:f4d6496660507b32a7f6ef05f5344bcd305ea3e2158a41bf53169663d7a72e70", size = 2608137, upload-time = "2026-09-01T08:27:12.405Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/37/25a84938b5ec34fc6123006a3f4173fead4fde3b460b516749a8ae8c1b02/genicam-1.6.0-cp313-cp313-manylinux2014_armv7l.whl", hash = "sha256:efe646df5e8ca76208c6621e8a5dbea3e9909641801787993ea2d7ec4a704c09", size = 2189839, upload-time = "2026-09-01T08:27:13.834Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/eb/37363beb1880674ff0976ff7607e2e045933c6388d08d98bc0b512df941c/genicam-1.6.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:a342980e80735b7b1e4f18f6c33d64eccc3e95887dd72f302409f1296bef3dac", size = 2799503, upload-time = "2026-09-01T08:27:15.253Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/38/895e9effffc858ff06996a9eed28decdc9b5b2176b2cfd82df8c4f65bdec/genicam-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:740156c5fa8d82330d36314112f4b755e8333c16f82792174f1a2a2e150519ae", size = 1802243, upload-time = "2026-09-01T08:27:16.576Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/26/98a864061d25e78b96b7d621814b399a78fb7603ae6a6ac871648587bc49/genicam-1.6.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:064a87188b6102bac03a8ab1ccff5280e8fa4cf6378448e6286ed6f4cc9f7a64", size = 2557508, upload-time = "2026-09-01T08:27:17.763Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/32/2ef1193d27bb375e3d9822be883e987d75203fcb417bafa96fd77fe8be18/genicam-1.6.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:3dcf4af1fabb1cee06cac0bed6560f0e49180dab1e078501949eaf07596736c3", size = 2609251, upload-time = "2026-09-01T08:27:19.161Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/06/ea8b6c2f4536cd8f54961040edae1ad9ff8a3dced2081a2377dac600bb73/genicam-1.6.0-cp314-cp314-manylinux2014_armv7l.whl", hash = "sha256:e3f9ff542bbd2fe389e1a94e4a694ec34f94ab378247c5f7e761b78ff5891d24", size = 2189734, upload-time = "2026-09-01T08:27:20.541Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/ff/a8c90b9a7d99fa6008437491f1198264c22d49572a0013a05bc1c579e034/genicam-1.6.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:d1e12e276eab6dbe8dec37e060ea5a8cc5758cc8c3502a048782e38a0451517d", size = 2799526, upload-time = "2026-09-01T08:27:21.77Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/18/c6f0b7f3da480aac0c86d104664e9cf144675986cc4e3f108c909bec0ddf/genicam-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7630b7844b273080f21f01881c56667cc68521186b75dd15798f5dee1f8f1037", size = 1846842, upload-time = "2026-09-01T08:27:23.18Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -888,19 +861,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "harvesters"
|
|
||||||
version = "1.4.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "genicam" },
|
|
||||||
{ name = "numpy" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/18/ee45d896fee51dee2e35df9d371e2d213ed4488ddea4dd4bd1df6f92a5fb/harvesters-1.4.3.tar.gz", hash = "sha256:c237dd402735a66ebd570865113a3e80ae19161a21706aed80691627a8cb7da2", size = 104391, upload-time = "2024-05-16T17:09:41.805Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/ab/295e5ec52cc2859ad8fa0d56f953cefa26f3e1eb28cc937162d7df7df872/harvesters-1.4.3-py3-none-any.whl", hash = "sha256:74994cedc941051b9ce9527c5b336e611589a1bc209bc03186c49102a55f1f1f", size = 100253, upload-time = "2024-05-16T17:09:27.933Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -1153,12 +1113,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nuitka"
|
|
||||||
version = "4.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/ba7cb858da0c98c24f85c54454a5ea18f2b784f658a91531fa051b2f2ee9/nuitka-4.3.tar.gz", hash = "sha256:8b102c6bf30d9504e82e69674d396972729092b25cefa680e8fd05678fdfa30b", size = 4766934, upload-time = "2026-10-10T11:13:15.577Z" }
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numba"
|
name = "numba"
|
||||||
version = "0.67.0"
|
version = "0.67.0"
|
||||||
@@ -1284,15 +1238,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ordered-set"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.11.8"
|
version = "3.11.8"
|
||||||
@@ -2098,18 +2043,6 @@ vision-worker = [
|
|||||||
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
|
||||||
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" },
|
|
||||||
{ name = "zstandard" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
||||||
@@ -2154,18 +2087,6 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
|
||||||
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" },
|
|
||||||
{ name = "zstandard", specifier = ">=0.22" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinycss2"
|
name = "tinycss2"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -2211,15 +2132,6 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
@@ -2318,31 +2230,6 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["dev", "fast", "dxf"]
|
provides-extras = ["dev", "fast", "dxf"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vs-camera"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { editable = "vendor/visionsuite/packages/vs-camera" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "opencv-python" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
genicam = [
|
|
||||||
{ name = "harvesters" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "flask", marker = "extra == 'dev'", specifier = ">=3.0" },
|
|
||||||
{ name = "harvesters", marker = "extra == 'genicam'", specifier = ">=1.4" },
|
|
||||||
{ name = "netifaces", marker = "extra == 'rete'", specifier = ">=0.11" },
|
|
||||||
{ name = "numpy", specifier = ">=1.24" },
|
|
||||||
{ name = "opencv-python", specifier = ">=4.8" },
|
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
|
||||||
]
|
|
||||||
provides-extras = ["genicam", "rete", "dev"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vs-dxf"
|
name = "vs-dxf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2369,21 +2256,6 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vs-license"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { editable = "vendor/visionsuite/packages/vs-license" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "cryptography" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "cryptography", specifier = ">=42" },
|
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
|
||||||
]
|
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vs-measure"
|
name = "vs-measure"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2488,15 +2360,6 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["locate", "dxf", "dev"]
|
provides-extras = ["locate", "dxf", "dev"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "waitress"
|
|
||||||
version = "3.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchfiles"
|
name = "watchfiles"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2713,77 +2576,3 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstandard"
|
|
||||||
version = "0.25.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
|
||||||
]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user