diff --git a/_freeze.py b/_freeze.py index 56136ce..54ec958 100755 --- a/_freeze.py +++ b/_freeze.py @@ -4,16 +4,16 @@ import sys import os import shutil -import glob +import tempfile +import time +import datetime import getopt -from cx_Freeze import setup, Executable -from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, APP_DESCRIPTION +from PyInstaller.__main__ import run as run_pyinstaller BASE_DIR = os.path.split( os.path.abspath(__file__) )[ 0 ] -BUILD_DIR = os.path.join( BASE_DIR, "build" ) -MAIN_ENTRY_POINT = "vasl_templates/main.py" +MAIN_SCRIPT = "vasl_templates/main.py" APP_ICON = os.path.join( BASE_DIR, "vasl_templates/webapp/static/images/app.ico" ) TARGET_NAMES = { @@ -23,24 +23,16 @@ DEFAULT_TARGET_NAME = "vasl-templates" # --------------------------------------------------------------------- -def get_extra_files(): - """Get the extra files to include in the release.""" - def globfiles( fspec ): #pylint: disable=missing-docstring,unused-variable - fnames = glob.glob( fspec ) - return zip( fnames, fnames ) - extra_files = [] - extra_files.append( "LICENSE.txt" ) - return extra_files - -# --------------------------------------------------------------------- - # parse the command-line options output_fname = None +work_dir = None cleanup = True -opts,args = getopt.getopt( sys.argv[1:], "o:", ["output=","noclean"] ) +opts,args = getopt.getopt( sys.argv[1:], "o:w:", ["output=","work=","noclean"] ) for opt,val in opts: if opt in ["-o","--output"]: output_fname = val.strip() + elif opt in ["-w","--work"]: + work_dir = val.strip() elif opt in ["--noclean"]: cleanup = False else: @@ -48,6 +40,18 @@ for opt,val in opts: if not output_fname: raise RuntimeError( "No output file was specified." ) +# figure out where to locate our work directories +if work_dir: + build_dir = os.path.join( work_dir, "build" ) + if os.path.isdir( build_dir ): + shutil.rmtree( build_dir ) + dist_dir = os.path.join( work_dir, "dist" ) + if os.path.isdir( dist_dir ): + shutil.rmtree( dist_dir ) +else: + build_dir = tempfile.mkdtemp() + dist_dir = tempfile.mkdtemp() + # figure out the format of the release archive formats = { ".zip": "zip", ".tar.gz": "gztar", ".tar.bz": "bztar", ".tar": "tar" } output_fmt = None @@ -59,54 +63,68 @@ for extn,fmt in formats.items(): if not output_fmt: raise RuntimeError( "Unknown release archive format: {}".format( os.path.split(output_fname)[1] ) ) -# initialize the build options -build_options = { - "packages": [ "os", "asyncio", "jinja2" ], - "excludes": [ "tkinter" ], - "include_files": get_extra_files(), -} +# configure pyinstaller +# NOTE: Using UPX gave ~25% saving on Windows, but failed to run because of corrupt DLL's :-/ +# NOTE: Setting --specpath breaks the build - it's being used as the project root...? (!) +target_name = TARGET_NAMES.get( sys.platform, DEFAULT_TARGET_NAME ) +args = [ + "--distpath", dist_dir, + "--workpath", build_dir, + "--onefile", + "--name", target_name, +] +# NOTE: We also need to include the config/ and data/ subdirectories, but we would like to +# make them available to the user, so we include them ourself in the final release archive. +def map_dir( src, dest ): #pylint: disable=missing-docstring + args.extend( [ "--add-data", src + os.pathsep + dest ] ) +map_dir( "vasl_templates/ui", "vasl_templates/ui" ) +map_dir( "vasl_templates/resources", "vasl_templates/resources" ) +map_dir( "vasl_templates/webapp/static", "static" ) +map_dir( "vasl_templates/webapp/templates", "templates" ) +if sys.platform == "win32": + args.append( "--noconsole" ) + args.extend( [ "--icon", APP_ICON ] ) + # NOTE: These files are not always required but it's probably safer to always include them. + import distutils.sysconfig #pylint: disable=import-error + dname = os.path.join( distutils.sysconfig.get_python_lib() , "PyQt5/Qt/bin" ) + args.extend( [ "--add-binary", os.path.join(dname,"libEGL.dll") + os.pathsep + "PyQt5/Qt/bin" ] ) + args.extend( [ "--add-binary", os.path.join(dname,"libGLESv2.dll") + os.pathsep + "PyQt5/Qt/bin" ] ) +args.append( MAIN_SCRIPT ) # freeze the application -# NOTE: It would be nice to be able to use py2exe to compile this for Windows (since it produces -# a single EXE instead of the morass of files cx-freeze generates) but py2exe only works up to -# Python 3.4, since the byte code format changed after that. -target = Executable( - MAIN_ENTRY_POINT, - base = "Win32GUI" if sys.platform == "win32" else None, - targetName = TARGET_NAMES.get( sys.platform, DEFAULT_TARGET_NAME ), - icon = APP_ICON -) -if os.path.isdir( BUILD_DIR ): - shutil.rmtree( BUILD_DIR ) +start_time = time.time() os.chdir( BASE_DIR ) -del sys.argv[1:] -sys.argv.append( "build" ) -# nb: cx-freeze doesn't report compile errors or anything like that :-/ -setup( - name = APP_NAME, - version = APP_VERSION, - description = APP_DESCRIPTION, - options = { - "build_exe": build_options - }, - executables = [ target ] -) -print() - -# locate the release files -files = os.listdir( BUILD_DIR ) -if len(files) != 1: - raise RuntimeError( "Unexpected freeze output." ) -dname = os.path.join( BUILD_DIR, files[0] ) -os.chdir( dname ) - -# remove some unwanted files -for fname in ["debug.cfg","logging.cfg"]: - fname = os.path.join( "lib/vasl_templates/webapp/config", fname ) +run_pyinstaller( args ) # nb: this doesn't return any indication if it worked or not :-/ + +# add extra files to the distribution +def ignore_files( dname, fnames ): #pylint: disable=redefined-outer-name + """Return files to ignore during copytree().""" + # ignore Python cached files + ignore = [ "__pycache__" ] + # ignore dot files + ignore.extend( f for f in fnames if f.startswith(".") ) + # ignore anything in .gitignore + fname = os.path.join( dname, ".gitignore" ) if os.path.isfile( fname ): - os.unlink( fname ) + for line_buf in open(fname,"r"): + line_buf = line_buf.strip() + if not line_buf or line_buf.startswith("#"): + continue + ignore.append( line_buf ) # nb: we assume normal filenames i.e. no globbing + return ignore +shutil.copy( "LICENSE.txt", dist_dir ) +shutil.copytree( "vasl_templates/webapp/data", os.path.join(dist_dir,"data") ) +shutil.copytree( "vasl_templates/webapp/config", os.path.join(dist_dir,"config"), ignore=ignore_files ) +# copy the examples +dname = os.path.join( dist_dir, "examples" ) +os.makedirs( dname ) +fnames = [ f for f in os.listdir("examples") if os.path.splitext(f)[1] in (".json",".png") ] +for f in fnames: + shutil.copy( os.path.join("examples",f), dname ) # create the release archive +os.chdir( dist_dir ) +print() print( "Generating release archive: {}".format( output_fname ) ) shutil.make_archive( output_fname2, output_fmt ) file_size = os.path.getsize( output_fname ) @@ -115,4 +133,11 @@ print( "- Done: {0:.1f} MB".format( float(file_size) / 1024 / 1024 ) ) # clean up if cleanup: os.chdir( BASE_DIR ) # so we can delete the build directory :-/ - shutil.rmtree( BUILD_DIR ) + os.unlink( target_name + ".spec" ) + shutil.rmtree( build_dir ) + shutil.rmtree( dist_dir ) + +# log the elapsed time +elapsed_time = time.time() - start_time +print() +print( "Elapsed time: {}".format( datetime.timedelta( seconds=int(elapsed_time) ) ) ) diff --git a/setup.py b/setup.py index 855595a..f068de3 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ setup( "lxml==4.2.4", "pylint==1.9.2", "pytest-pylint==0.9.0", - "cx-Freeze==5.1.1", + "PyInstaller==3.4", ], }, include_package_data = True, diff --git a/vasl_templates/main_window.py b/vasl_templates/main_window.py index dcbbe1e..fe73e32 100644 --- a/vasl_templates/main_window.py +++ b/vasl_templates/main_window.py @@ -1,5 +1,6 @@ """ Main application window. """ +import sys import os import re import json @@ -54,8 +55,12 @@ class MainWindow( QWidget ): # initialize the main window self.setWindowTitle( APP_NAME ) + if getattr( sys, "frozen", False ): + dname = sys._MEIPASS #pylint: disable=no-member,protected-access + else: + dname = os.path.join( os.path.split(__file__)[0], "webapp" ) self.setWindowIcon( QIcon( - os.path.join( os.path.split(__file__)[0], "webapp/static/images/app.ico" ) + os.path.join( dname, "static/images/app.ico" ) ) ) # create the menu diff --git a/vasl_templates/webapp/__init__.py b/vasl_templates/webapp/__init__.py index 9668bf7..71337ef 100644 --- a/vasl_templates/webapp/__init__.py +++ b/vasl_templates/webapp/__init__.py @@ -1,5 +1,6 @@ """ Initialize the package. """ +import sys import os import configparser import logging @@ -28,7 +29,15 @@ def load_debug_config( fname ): # --------------------------------------------------------------------- # initialize Flask -app = Flask( __name__ ) +if getattr( sys, "frozen", False ): + # NOTE: The support directories must have been set up by pyinstaller (via --add-data). + meipass = sys._MEIPASS #pylint: disable=no-member,protected-access + app = Flask( __name__, + template_folder = os.path.join( meipass, "templates" ), + static_folder = os.path.join( meipass, "static" ) + ) +else: + app = Flask( __name__ ) # load the application configuration config_dir = os.path.join( BASE_DIR, "config" ) diff --git a/vasl_templates/webapp/config/constants.py b/vasl_templates/webapp/config/constants.py index 24fd299..1068d6e 100644 --- a/vasl_templates/webapp/config/constants.py +++ b/vasl_templates/webapp/config/constants.py @@ -1,10 +1,14 @@ """ Application constants. """ +import sys import os APP_NAME = "VASL Templates" APP_VERSION = "v0.4" # nb: also update setup.py APP_DESCRIPTION = "Generate HTML for use in VASL scenarios." -BASE_DIR = os.path.abspath( os.path.join( os.path.split(__file__)[0], ".." ) ) +if getattr( sys, "frozen", False ): + BASE_DIR = os.path.split( sys.executable )[0] +else: + BASE_DIR = os.path.abspath( os.path.join( os.path.split(__file__)[0], ".." ) ) DATA_DIR = os.path.join( BASE_DIR, "data" )