คอมไพล์ Python Script เป็นไฟล์ Executable ด้วย PyInstaller

PyInstaller

คือเครื่องมือที่ช่วยการแปลงโปรแกรมที่เขียนด้วยไพทอนเป็น execute binary file  ที่สามารถนำไปรันได้โดยที่เครื่องคอมพิวเตอร์ปลายทางไม่ต้องติดตั้งไพทอน สำหรับ PyInstaller เป็น cross-platform สามารถใช้งานได้บนวินโดส์ แมค และลีนุกซ์ สนับสนุนไพทอนรุ่น 2.7 และ ไพทอน รุ่น 3.3 ถึง 3.6 จุดมุ่งหมายของ PyInstaller คือต้องการช่วยผู้ใช้ในการแปลงโปรแกรมไพทอน ที่ใช้โมดูลไลบรารีภายนอกเช่น Matplotlib, DJango, wxPython, PyQt เป็นต้น ให้สามารถทำได้ง่ายสะดวก

ติดตั้ง PyInstaller

ติดตั้งง่ายๆด้วยคำสั่ง pip ใน command prompt

pip install pyinstaller

ใช้งาน PyInstaller

การใช้งานสามารถใช้งานผ่าน command line ได้ แต่สำหรับโปรแกรมที่เรียกใช้โมดูลไลบรารีข้างนอกและต้องขนข้อมูล (data) ที่โมดูลไลบรารีนั้นๆต้องการใช้  ผมแนะนำให้ใช้ไฟล์สคริปท์ (Spec file) มาช่วยจะดีกว่า ปรับแต่งได้มากกว่า ตัว spec file จริงๆก็คือไฟล์สคริปท์ของไพทอนนั่นเอง กรณีที่ต้องใช้ Spec file อีกกรณีหนึ่งคือต้องการขนรันไทม์ไลบรารีเช่น .dll หรือ .so ไปแบบแมนวล กรณีที่ผมเจอคือผมใช้ PySide2 ที่รุ่นทางการจริงๆยังไม่ออกมา แต่ hook file ก็มีมาให้แล้วพร้อมกับ PyInstaller รุ่นใหม่ 3.3 แต่ผมใช้งานแล้วยังไม่สำเร็จ ดังนั้นจึงต้องใช้ Spec file นี้เป็นตัวช่วยในการขนรันไทม์ไลบรารีไป ส่วนเรื่อง hook file คืออะไรค่อยว่าอีกที

กรณีศึกษาด้วย Surveyor Pocket Tools บนวินโดส์

โปรแกรม Surveyor Pocket Tools พัฒนาด้วยไพทอน ปัจจุบันใช้ไพทอน รุ่น 3.6 ใช้โมดูลไลบรารีข้างนอกคือ openpyxl, pyproj, geographiclib, gmplot, simplekml, pyshp และที่ขาดไม่ได้คือ PySide2 ซึ่งสำหรับ openpyxl และ pyproj จะมีการขนข้อมูลไปด้วย ส่วน PySide2 ผมจะขนไฟล์ dll  ที่ต้องการด้วยมือล้วนๆ

Spec file ของ Surveyor Pocket Tools

มาดูไฟล์สคริปนี้ ผมตั้งชื่อว่า “setup.spec”

# -*- mode: python -*-
# -*- mode: python -*-
import sys
import PySide2
import os
block_cipher = None
 
dirname = os.path.dirname(PySide2.__file__)
plugins_path = os.path.join(dirname, 'plugins', '')
 
pyside2_plugins = [(plugins_path + 'iconengines/*', 'plugins/iconengines/'),
                  (plugins_path + 'imageformats/*', 'plugins/imageformats/'),
		  (plugins_path + 'platforms/*', 'plugins/platforms/'),
		  (plugins_path + 'printsupport/*', 'plugins/printsupport/'),
		  (plugins_path + 'sqldrivers/*', 'plugins/sqldrivers/')]
 
added_files = [('markers/*', 'markers/'),
               ('geoids/*', 'geoids/'),
	       ('database/*', 'database/'),
	       ('example data/*', 'example data/'),
	       ('qt.conf', ''), 
	       ('*.xml', '')]
 
a = Analysis(['main.py'],
             pathex=['D:\\sourcecodes\\python\\surveyor pocket tools'],
             binaries=None,
             datas=added_files + pyside2_plugins,
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
          a.scripts,
          exclude_binaries=True,
          name='surveyor pocket tools',
		  icon='Land Surveying-64.ico',
          debug=False,
          strip=False,
          upx=False,
          console=False )
coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=True,
               name='setup')

ลองมาดูโค้ดกัน เริ่มจาก import PySide2 เข้ามาเพื่อจะตรวจสอบว่า PySide2 ที่เราใช้งานเป็น 32 บิตหรือ 64 บิต เพื่อจะได้ขน .dll ไปถูกรุ่น จากนั้นเก็บไดเรคทอรีของ PySide2 เข้าเก็บใน dirname ผ่านฟังก์ชัน os.path.dirname() ที่นี้เราทราบว่าในไดเรคทอรีของ PySide2 จะมีไดเรคทอรีย่อยชื่อ “plugins” อยู่ ทำการเก็บไดเรคทอรีนี้ด้วยฟังก์ชั่น os.path.join() ไปเก็บไว้ในตัวแปร plugins_path

# -*- mode: python -*-
import sys
import PySide2
import os
block_cipher = None
 
dirname = os.path.dirname(PySide2.__file__)
plugins_path = os.path.join(dirname, 'plugins', '')

ต่อไปคือตัวแปร pyside2_plugins จะเป็นลิสต์เก็บ tuple โดยสมาชิกตัวแรกจะเก็บชื่อไฟล์ไดเรคทอรีต้นทาง ใช้เครื่องหมาย * เพราะต้องการทุกๆไฟล์ในไดเรคทอรีนี้ สมาชิกตัวที่สอง จะเก็บชื่อไดเรคทอรีปลายทางที่ต้องการไฟล์เหล่านี้ไปอยู่

 
pyside2_plugins = [(plugins_path + 'iconengines/*', 'plugins/iconengines/'),
                   (plugins_path + 'imageformats/*', 'plugins/imageformats/'),
		   (plugins_path + 'platforms/*', 'plugins/platforms/'),
		   (plugins_path + 'printsupport/*', 'plugins/printsupport/'),
		   (plugins_path + 'sqldrivers/*', 'plugins/sqldrivers/')]

ลองมาดูว่าไดเรคทอรี “plugins” ผมไฮไลท์ไว้เฉพาะไดเรคทอรีที่โปรแกรม Surveyor Pocket Tools ต้องการ

ต่อไปจะขนไฟล์ที่โปรแกรม Surveyor Pocket Tools ต้องการใช้ ให้ใส่ไว้ที่ตัวแปร added_files โครงสร้างเป็น tuple เหมือนกัน และขนไฟล์ชื่อ qt.conf ที่ PySide2 ต้องการไปด้วย

added_files = [('markers/*', 'markers/'),
               ('geoids/*', 'geoids/'),
	       ('database/*', 'database/'),
	       ('example data/*', 'example data/'),
	       ('qt.conf', ''), 
	       ('*.xml', '')]

มาดูไดเรคทอรีที่โปรแกรมต้องการดังนี้

ต่อไปมาดูโค้ดส่วนที่สำคัญมาก ‘main.py’ คือไฟล์สคริปท์หลักของโปรแกรม Surveyor Pocket Tools ต่อไปคือ pathex เป็นไดเรคทอรีของไฟล์ไพทอนสคริปท์ และ datas ที่ผมจัดการรวม added_files และ pyside2_plugins เข้าด้วยกัน สุดท้าย hookspath คือไดเรคทอรีที่เก็บไฟล์ hook ไว้ สำหรับไฟล์ hook นี้ PyInstaller จะอ่านสคริปท์นี้ทีละไฟล์มาตัดสินใจว่าจะขนข้อมูลไดเรคทอรีไหนไป ผมเลือกใช้ดีฟอลท์ครับคือปล่อยว่าง

a = Analysis(['main.py'],
             pathex=['D:\\sourcecodes\\python\\surveyor pocket tools'],
             binaries=None,
             datas=added_files + pyside2_plugins,
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher)

สำหรับไดเรอทอรี hooks ที่เป็นดีฟอลท์มากับ PyInstaller ผมใช้ไฟล์เพียงสองไฟล์เท่านั้น ตามที่ไฮไลท์ไว้

ใช้ PyInstaller คอมไพล์ไฟล์ setup.spec

ผมใช้ Minoconda เมื่อจะคอมไพล์ก็เรียก command prompt มาดังนี้ ใช้คำสั่ง cd เข้ามาที่พาทของสคริปท์ของไพทอน ใช้คำสั่ง dir ดูไฟล์ setup.spec

ต่อไปทำการคอมไพล์ ด้วยคำสั่ง

pyinstaller setup.spec

ผลลัพธ์ของ PyInstaller

เมื่อคอมไพล์เสร็จแล้ว ไม่มี error จะได้ไดเรคทอรีมาสองคือ “build” และ “dist” เมื่อเข้าไปดูใน “dist” จะเห็นไดเรคทอรีย่อยช “setup” ชื่อไดเรคทอรีนี้ PyInstaller จะสร้างตามชื่อหน้าของไฟล์ setup.spec เมื่อเข้าดูที่ไดเรคทอรี “setup” จะเห็นไฟล์ต่างๆที่โปรแกรมต้องการ

ผมลองดับเบิ้ลคลิกไฟล์ “surveyor pocket tools.exe” ก็สามารถเปิดมาและทำงานได้ตามปกติ ลองดูชื่อไดเรคทอรีจะเห็นสองไดเรคทอรี ที่ได้จากไฟล์ hooks คือ openpyxl และ pyproj ลองเข้าไปดูในไดเรคทอรี จะเห็นข้อมูลที่ pyproj ขนไปใช้ หมายเหตุว่าข้อมูลนี้ pyproj จะนำไปเป็นฐานข้อมูลในการแปลงพิกัดตาม datum และ projection

ทำไฟล์ Setup ด้วย Inno Setup

จากนั้นผมจะ copy ไดเรคทอรีที่อยู่ใน “setup” ไปไว้อีกที่หนึ่ง พื้นที่นี้สำหรับใช้ Inno Setup มาทำไฟล์ติดตั้ง ลองดูไดเรคทอรี

ในไดเรคทอรีนี้ผมจะมีไฟล์ “surveyorpockettools64.iss” เป็นไฟล์สคริปท์ของ Inno Setup เพื่อสร้างไฟล์ติดตั้ง setup สำหรับวินโดส์ 64 บิต

#define MyAppName "Surveyor Pocket Tools"
#define MyAppEXE "Surveyor Pocket Tools.exe"
#define MyShortAppName "SurveyorPocketTools"
#define MyMainRoot "Survey Suite"
#define Developer "Prajuab Riabroy"
#define Version "0.98"
#define Build "573"
 
[Setup]
AppName={#MyAppName}
AppVerName={#MyAppName} V{#Version}
DefaultDirName={pf}\{#MyMainRoot}\{#MyAppName}
DefaultGroupName={#MyMainRoot}\{#MyAppName}
UseSetupLdr=yes
UninstallDisplayIcon={app}\{#MyAppEXE}
VersionInfoProductName={#MyAppName}
VersionInfoCompany=priabroy
VersionInfoCopyright=Copyright 2000-2017 by {#Developer}
VersionInfoDescription={#MyAppName}
VersionInfoProductVersion={#Version}
VersionInfoVersion={#Version}
OutputDir=Setup
OutputBaseFilename={#MyShortAppName}V{#Version}Build{#Build}Setup64
;OutputDir=TraverseProV250Setup64
; "ArchitecturesAllowed=x64" specifies that Setup cannot run on
; anything but x64.
ArchitecturesAllowed=x64
; "ArchitecturesInstallIn64BitMode=x64" requests that the install be
; done in "64-bit mode" on x64, meaning it should use the native
; 64-bit Program Files directory and the 64-bit view of the registry.
;ArchitecturesInstallIn64BitMode=x64
ArchitecturesInstallIn64BitMode=x64
AppPublisher={#Developer}
AppPublisherURL=https://www.surveyorpockettools.org
AppVersion={#Version}.{#Build}
LicenseFile = eula.txt
ChangesEnvironment=yes
SolidCompression=yes
Compression=lzma2/ultra64
LZMAUseSeparateProcess=yes
LZMADictionarySize=1048576
LZMANumFastBytes=273
 
[Files]
Source: "{#MyAppName}.exe"; DestDir: "{app}"
Source: "base_library.zip"; DestDir: "{app}" ;
Source: "plugins\*"; DestDir: "{app}\plugins\"; Flags: ignoreversion recursesubdirs
Source: "database\*"; DestDir: "{userappdata}\{#MyAppName}\database\";
Source: "geoids\*"; DestDir: "{userappdata}\{#MyAppName}\geoids\";
Source: "markers\*"; DestDir: "{userappdata}\{#MyAppName}\markers\";
Source: "example data\*"; DestDir:"{userappdata}\{#MyAppName}\example data\";
Source: "pyproj\data\*"; DestDir: "{app}\pyproj\data\";
Source: "openpyxl\*"; DestDir: "{app}\openpyxl\";
;Source: "requests\*"; DestDir: "{app}\requests\";
Source: "*.html"; DestDir: "{userappdata}\{#MyAppName}\";
Source: "*.dll"; DestDir: "{app}"
Source: "*.pyd"; DestDir: "{app}"
Source: "*.xml"; DestDir: "{userappdata}\{#MyAppName}\";
Source: "qt.conf"; DestDir: "{app}"
 
[Icons]
;create icon at start menu group
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExe}"
;create icon at desktop
Name: "{commondesktop}\{#MyAppName}"; FileName:"{app}\{#MyAppExe}"
 
[Registry]
; Start "Software\My Company\My Program" keys under HKEY_CURRENT_USER
; and HKEY_LOCAL_MACHINE. The flags tell it to always delete the
; "My Program" keys upon uninstall, and delete the "My Company" keys
; if there is nothing left in them.
Root: HKCU; Subkey: "Software\{#MyMainRoot}"; Flags: uninsdeletekeyifempty
Root: HKCU; Subkey: "Software\{#MyMainRoot}\{#MyAppName}"; Flags: uninsdeletekey
Root: HKLM; Subkey: "Software\{#MyMainRoot}"; Flags: uninsdeletekeyifempty
Root: HKLM; Subkey: "Software\{#MyMainRoot}\{#MyAppName}"; Flags: uninsdeletekey
Root: HKLM; Subkey: "Software\{#MyMainRoot}\{#MyAppName}\Settings"; ValueType: string; ValueName: "InstalledPath"; ValueData: "{app}"
Root: HKLM; Subkey: "Software\{#MyMainRoot}\{#MyAppName}\Settings"; ValueType: string; ValueName: "DevelopedBy"; ValueData: "{#Developer}"
Root: HKLM; Subkey: "Software\{#MyMainRoot}\{#MyAppName}\Settings"; ValueType: string; ValueName: "ApplicationName"; ValueData: "{#MyAppName}"
;Root: HKCU; Subkey: "Environment"; ValueType:string; ValueName:"PROJ_LIB"; ValueData:"{userappdata}\{#MyAppName}\geoidgrids\" ; Flags: preservestringtype ;

ถ้าเป็นไฟล์สำหรับ Surveyor Pocket Tools รุ่น 32 บิตเพียงใส่คอมเมนต์หน้า ;ArchitecturesInstallIn64BitMode=x64 ก็พอ สำหรับรายละเอียดสคริปท์ของ Inno Setup ผมจะไม่กล่าวถึงรายละเอียดในที่นี้ผู้อ่านที่สนใจสามารถศึกษาได้ครับ จากนั้นก็ใช้ Inno Setup ทำการ build ก็จะได้ไฟล์ Exe เดี่ยวๆ ที่สามารถ zip ไปให้ผู้ใช้ได้ download ต่อไป พบกันตอนหน้าครับ

แนะนำการย้ายโค้ดจาก PyQt5 เป็น PySide2

ย้ายโค้ด XSection Plot

ในขณะนี้ทำงานอยู่ที่บังคลาเทศ โครงการก่อสร้างรถไฟฟ้าที่กรุงธากา มีโอกาสกลับมาพัก ก็พอมีเวลาว่างพยายามย้ายโค้ดของโปรแกรม XSection Plot จากของเดิมที่พัฒนาด้วย PyQt5 ที่ยังติดเรื่องลิขสิทธิ์บางส่วน โดยย้ายมาใช้ PySide2 ที่เปิดกว้างกว่า ความจริงทั้งคู่ใช้เครื่องยนต์ (Engine) เดียวกันคือ Qt5 platform ดังนั้นเมื่อย้ายโค้ดสำเร็จแล้วเวลารันก็หน้าตาเหมือนกันเป๊ะดังรูปด้านล่างที่คอมไพล์ด้วย PySide2

จัดการปลั๊กอิน PySide2

การย้ายโค๊ดใช้เวลาไม่นานนัก ใช้เวลาประมาณ 2 ชั่วโมง เนื่องจากผมเคยย้ายโค้ด Surveyor Pocket Tools ทำให้รู้แนวทางลัดพอสมควร อันดับแรกขอย้อนกลับหน่อย เนื่องจาก PySide2 จะมองหาโฟลเดอร์ลิ๊งค์ไลบรารีของตัวเองชื่อ “plugins” ถ้าไม่เจอจะ error แล้วหยุดทันที ดังนั้นก่อนอื่นควรจะแทรกโค๊ดนี้เข้าไปก่อน เริ่มตั้งแต่ import PySide2 ตามด้วยตรวจสอบว่า PySide2 อยู่ที่โฟลเดอร์ไหนจัดเก็บเข้าตัวแปร dirname จากนั้นค้นหาพาทของ “plugins” ที่อยู่ใต้โฟลเดอร์ dirname ด้วยคำสั่ง os.path.join()

import os
import sys
import PySide2
dirname = os.path.dirname(PySide2.__file__)
plugin_path = os.path.join(dirname, 'plugins', '')
os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = plugin_path
print('plugin_path = ', plugin_path)

ที่เครื่องคอมพิวเตอร์ผม จะปริ๊นท์พาทของ “plugins” ดังนี้

plugin_path =  C:\Miniconda3\envs\py36_64\lib\site-packages\PySide2\plugins\

เพราะว่าผมใช้ Miniconda เป็นตัวจัดการระบบ environment ของ python ผมสร้าง envs ชื่อ “py36_64” เป็น python รุ่น 3.6 แบบ 64 บิต และ PySide2 ก็จะถูกติดตั้งมาอยู่ภายใต้โฟลเดอร์นี้ อีก envs หนึ่งที่สร้างไว้ชื่อ “py36_32” เป็น python รุ่น 3.6 แบบ 32 บิต เมื่อรันโปรแกรมแล้วจะปริ๊นท์พาทมาดังนี้

plugin_path =  C:\Miniconda3\envs\py36_32\lib\site-packages\PySide2\plugins\

วิธีการสร้าง environment สำหรับไพทอนก็กลับไปดูโพสต์เก่าของผมได้ครับ

เปลี่ยนคำ PyQt5 เป็น PySide2

ยังอยู่ในส่วน import ที่โค๊ดเดิมของโปรแกรมผมเรียกใช้ไลบรารีของ PyQt5 ดังนี้

from PyQt5.QtGui import QPixmap, QIcon, QKeySequence, QFont, QIntValidator, QCursor
from PyQt5.QtCore import Qt, pyqtSlot, QSettings, QFileInfo, QSize, QFile
from PyQt5.QtWidgets import QUndoStack, QSplashScreen, QApplication, QMainWindow, QTabWidget, QAction, QStatusBar,\
     QMenu, QWidget, QSizePolicy, QLineEdit, QFileDialog, QMessageBox, QDesktopWidget

เปลี่ยนเป็น

from PySide2.QtGui import QPixmap, QIcon, QKeySequence, QFont, QIntValidator, QCursor
from PySide2.QtCore import Qt, Slot, QSettings, QFileInfo, QSize, QFile
from PySide2.QtWidgets import QUndoStack, QSplashScreen, QApplication, QMainWindow, QTabWidget, QAction, QStatusBar,\
     QMenu, QWidget, QSizePolicy, QLineEdit, QFileDialog, QMessageBox, QDesktopWidget

ส่วนใหญ่เกือบ 99.99% ที่เหมือนกัน ยกเว้น Signal & Slot

Signal and Slot

มีข้อแตกต่างกันเล็กน้อย เช่นเดิมใน PyQt5 เรียกใช้ pyqtSlot, pyqtSignal ให้เปลี่ยนเป็น Slot, Signal ใน PySide2 ครับ
นอกจากส่วน import แล้ว ในโค๊ดเดิมที่ประกาศคลาส โค้ดเดิมผมเรียกใช้ Signal and Slot ดังนี้

class OverlapSection(QObject):
    '''Horizontal & Vertical overlapped.'''
    overlapped = pyqtSignal(str)
 
    def __init__(self):
            QObject.__init__(self)
 
    def emitOverlapSignal(self, message):
        self.overlapped.emit(message)

เปลี่ยนใหม่เป็น

class OverlapSection(QObject):
    '''Horizontal & Vertical overlapped.'''
    overlapped = Signal(str)
 
    def __init__(self):
            QObject.__init__(self)
 
    def emitOverlapSignal(self, message):
        self.overlapped.emit(message)

ติดตั้ง PySide2 จากไฟล์ wheel

ก็ขอแนะอีกนิดว่า PySide2 เวลาติดตั้งให้ใช้แพ๊คเกจแบบ wheel ที่ทางทีมงานได้ทำไว้ดีกว่าครับ ติดตั้งง่ายไม่งอแง ดาวน์โหลดได้ตามลิ๊งค์นี้ จะสังเกตเห็นชื่อไฟล์ประมาณนี้

PySide2-5.6-cp35-cp35m-win32.whl 	 
PySide2-5.6-cp35-cp35m-win_amd64.whl 	 
PySide2-5.6-cp36-cp36m-win32.whl 
PySide2-5.6-cp36-cp36m-win_amd64.whl

จะเห็นว่า PySide2 สนับสนุนทั้งไพทอน 3.5 และ 3.6 และในตอนนี้ Qt5 รุ่น  5.6 สำหรับคำสั่งที่ติดตั้งก็ง่ายๆใช้ pip ตามด้วยชื่อไฟล์ wheel

pip install PySide2-5.6-cp36-cp36m-win_amd64.whl

สรุปแล้วการย้ายโค้ดง่ายๆไม่ลำบากกินแรง แต่ไปกินแรงเข็นครกอีกทีคือตอนสร้างไบนารีไฟล์ด้วย Pyinstaller ความจริง Pyinstaller ถ้าเข้าใจแล้วปรับใช้ได้ไม่ยาก แต่สำหรับมือใหม่บอกตรงๆว่า ถ้าโปรแกรมที่พัฒนาเรียกใช้ไลบรารีมากหลายอันแล้ว เป็นนรกลูกย่อมๆครับ ถ้าไลบรารีตัวไหนมีคนเขียนไฟล์ hook ให้ก็ง่ายหน่อย แต่ถ้าไม่มีต้องออกแรงกันพอสมควร สำหรับ PySide2 ผมจัดการแบบ manual ครับ รู้ว่าตอนโปรแกรมรันมันต้องการอะไร ตอนใช้ Pyinstaller ผมก็จัดการ copy ไฟล์ไปตามต้องการ ถ้ามีโอกาสจะมาเขียนเรื่องการใช้ Pyinstaller อีกสักตอน พบกันตอนหน้าครับ