Distribuire un programma - il modo facile.

 Aggiornamento: a partire dalle riflessioni di questo post, ho scritto WinPackIt. Vedi qui.


Lo confesso, questa eterna domanda "come faccio a compilare e distribuire il mio programma" mi ha sempre un po' infastidito. Intanto perché, in generale, non è una cosa facile: Python non "compila", per cominciare. Poi perché ormai, in questa era del cloud, il modo canonico di distribuire il codice Python sta diventando quello di offrirlo come servizio invece di come prodotto: vale a dire, gestire l'installazione di Python e del proprio codice su un server, e lasciare che gli utenti vi si colleghino con un browser o magari interrogando una api web.
Detto questo, l'esigenza tipica del principiante ("metti che adesso scrivo un programma, ma come faccio a darlo al mio amico Gigi che non ha Python installato sul suo computer?") è pur sempre legittima. Non è che manchino i tool per "congelare" in qualche modo uno script Python e prepararlo per la distribuzione: ci sono cx_freeze, py2exe, Pyinstaller, py2app e via discorrendo. Ma ciascuno di questi prodotti ha i suoi pro e contro, le sue idiosincrasie, i suoi difettucci. E si tratta comunque di imparare uno strumento nuovo, capire a fondo quello che fa, non perdere la testa quando non fa quello che si vuole... insomma, è comunque una bella fatica.

Intanto però Python, almeno su Windows, a partire dalla versione 3.5.2 ha introdotto una novità molto interessante, passata purtroppo in sordina: una distribuzione sotto forma di pacchetto zip "embeddable". Potete trovarla accanto alle altre, nella consueta pagina dei download: per esempio per la versione 3.7.3 (la più recente al momento in cui scrivo) potete trovarla qui, denominata "Windows x86-64 embeddable zip file". Si tratta di un normale file zip che contiene una versione minima funzionante di Python: non c'è la documentazione, manca Idle e tutta una serie di accessori, a partire da Pip. C'è però l'interprete python.exe (e anche il più comodo pythonw.exe che esegue lo script senza mostrare la finestra della shell, come forse saprete) e c'è tutta la libreria standard compressa a sua volta in uno zip interno (che non occorre scompattare: ricordiamo che Python è in grado di importare i moduli anche all'interno di un archivio zip).
L'idea di questa distribuzione è di rendere più semplice l'inclusione di Python all'interno di altri programmi (magari scritti in altri linguaggi). Se volete approfondire potete leggere questo articolo di Steve Dower. Si può però anche usare questo zip in modo proficuo per creare distribuzioni "portatili" dei nostri programmi. In effetti esiste un tool (Pynsist) che fa proprio questo, appoggiandosi anche al package builder NSIS - insomma, un'altra soluzione potenzialmente interessante ma anche un po' complicata.

Io però voglio proporvi invece un piccolo espediente per usare la distribuzione "zippata" di Python per distribuire il nostro programma in modo semplice, senza nessun bisogno di strumenti esterni. Si tratta di un sistema meno elegante ma veloce e facile da capire. In pratica occorre:
- il file zip della distribuzione "embeddable" di Python;
- i file del nostro programma, eventualmente in un package o comunque in una directory;
- che il nostro programma dipenda solo dai moduli della libreria standard oppure da pacchetti esterni installati con Pip dentro un virtual environment;
- infine, occorre tener presente che questo sistema funzionerà solo su Windows.

Ed ecco quello che dovete fare:
- estrarre il file "zip" della distribuzione di Python in una directory vuota;
- al suo interno, creare inoltre una directory (chiamiamola "my_project" per esempio) dove copiate semplicemente tutti i file del vostro programma, così come sono;
- sempre all'interno della directory-madre, creare un'altra directory (che per convenzione conviene chiamare "vendor", visto che quello che state per fare è appunto un vendoring) dove copiate tutti i package esterni usati dal vostro programma: li trovate nella directory ...\Lib\site-packages del vostro virtual environment.
- modificare il file "python37._pth" (che controlla la sys.path del vostro ambiente "embedded") in modo da aggiungere le due directory appena create;
- infine, create un file batch "start.bat" dove impostate la directory corrente giusta e avviate il programma con l'interprete Python corretto.
L'utente finale dovrà semplicemente copiare tutto questo sul suo computer e avviare il programma facendo doppio clic sul file batch. Potete anche, naturalmente, creare un collegamento al file batch e dotarlo di un'icona personalizzata, se volete aggiungere un po' di colore.

Riassumendo, la directory della vostra "build" avrà questa struttura:
my_build
       |- my_project
       |      (qui i file del programma)
       |
       |- vendor
       |      (qui i file dei package esterni)
       |
       |- start.bat
       |- python37._pth
       |- python.exe
       |- pythonw.exe ...
       |- ... e tutti i file estratti da python-3.7.3-embed-amd64.zip
Se questa è la struttura della directory, allora il file "start.bat" dovrà semplicemente contenere qualcosa del genere:
cd my_project
"..\pythonw.exe" my_script.py

La prima riga imposta la directory di lavoro corretta, e la seconda avvia l'entry-point del vostro programma con l'interprete che si trova nella directory superiore.

Siccome tutto questo procedimento non è altro che la copia e la modifica di un po' di file, la cosa più conveniente è farsi uno script che automatizza tutto questo.
Supponete di avere un progetto Python (chiamiamolo "magazzino"... un tipico gestionale!) che, seguendo la struttura suggerita nella mia guida,  è organizzato in questo modo:
magazzino
       |- magazzino
       |     (qui i moduli del codice)
       |
       |- build (la directory che contiene le build)
       |    |
       |    |- python-3.7.3-embed-amd64.zip
       |
       |- build.py
       |- (vari file accessori, test, documentazione...)
Conservate per comodità il file della distribuzione "embedded" di Python dentro la directory "build" (vale la pena di sottolinearlo: se usate un version control system come Git, allora la directory "build" dovrà essere aggiunta a .gitignore per escluderla dalla sorveglianza).

Fatto questo, una bozza di script "build.py" per automatizzare il processo potrebbere essere:
# build.py

import sys
import os
import os.path
from shutil import copytree, ignore_patterns
import datetime
import zipfile

# CONFIGURAZIONE
# --------------
PROJECT_NAME = 'magazzino'  # nome del progetto
SOURCE_ROOT = './magazzino' # root del codice (relativa a questo file)
ENTRY_POINT_SCRIPT = 'main.py' # script da eseguire

BUILD_ROOT = './build'  # root delle builds (relativa a questo file)
VENDOR_DIRNAME = 'vendor' # nome della dir dei packages non-standard lib
CODE_DIRNAME = 'project'  # nome della dir del codice del progetto
PYTHON_ZIP_FILE = 'python-3.7.3-embed-amd64.zip' # python zippato da copiare

# pattern da ignorare quando si copiano i vendor packages e i file del progetto
IGNORE_PACKAGES_PATTERNS = ('__pycache__',)
IGNORE_CODE_PATTERNS = ('__pycache__',)


# -----------------------------------------------------------------------
PYTHON_ZIP_PATH = os.path.join(BUILD_ROOT, PYTHON_ZIP_FILE)
THIS_BUILD_PATH = os.path.join(BUILD_ROOT, 
                               datetime.datetime.now().strftime("%y%m%d_%H%M%S"))
PACKAGES_PATH_ORIG = os.path.join(os.path.dirname(sys.executable), 
                                  '../Lib/site-packages' )
PACKAGES_PATH_DEST = os.path.join(THIS_BUILD_PATH, VENDOR_DIRNAME)
PROJECT_PATH_ORIG = SOURCE_ROOT
PROJECT_PATH_DEST = os.path.join(THIS_BUILD_PATH, CODE_DIRNAME)

def build():
    os.mkdir(THIS_BUILD_PATH)

    print('scompatto la distribuzione Python...')
    with zipfile.ZipFile(PYTHON_ZIP_PATH, 'r') as python_zip:
        python_zip.extractall(THIS_BUILD_PATH)

    print('copio i package richiesti...')
    copytree(PACKAGES_PATH_ORIG, PACKAGES_PATH_DEST, 
             ignore=ignore_patterns(*IGNORE_PACKAGES_PATTERNS))

    print('sistemo la sys.path...')
    with open(os.path.join(THIS_BUILD_PATH, 'python37._pth'), 'a') as f:
        f.write(VENDOR_DIRNAME+'\n')
        f.write(CODE_DIRNAME+'\n')

    print('copio i moduli del programma...')
    copytree(PROJECT_PATH_ORIG, PROJECT_PATH_DEST,
             ignore=ignore_patterns(*IGNORE_CODE_PATTERNS))

    print('creo un file batch come entry point...')
    namefile = 'start_%s.bat' % PROJECT_NAME
    cmd_txt = 'cd %s\n"..\\python.exe" %s\n' % (CODE_DIRNAME, ENTRY_POINT_SCRIPT)
    with open(os.path.join(THIS_BUILD_PATH, namefile), 'a') as f:
        f.write(cmd_txt)

    msg = '\Fatto!\nPuoi fare doppio clic su "%s" per avviare il programma.' % namefile
    print(msg)

if __name__ == '__main__':
    build()
Questo script crea una directory in "build" (con un nome variabile che dipende dalla data e ora attuali) che contiene tutto quanto serve per distribuire ed eseguire il vostro programma.
Vale la pena di ricordarlo ancora: è fortemente consigliabile usare un virtual environment nel quale avete installato solo i pacchetti esterni che effettivamente vi servono per il programma. Viceversa, questo script funzionerà senza problemi, ma "pescherà" tutti i pacchetti installati nel Python di sistema, anche quelli che non servono, e riempirà la vostra directory di distribuzione di un sacco di robaccia inutile.

E questo è tutto... Ripeto, non è una soluzione elegante e compatta come quella che offrono i tool "seri" in circolazione... ma è concettualmente molto semplice e facile da tenere sotto controllo, e ha il vantaggio di non ricorrere a nessuno strumento aggiuntivo.

Commenti

Post più popolari