Distribute a Windows Python app with Inno Setup

Anyone who’s done a bit of work with Python knows the Achilles heel of the language is the distribution system. In Python 3.6, the entire standard library distutils is “Legacy.” Then you have setuptools and numpy.distutils, both of which monkey-patch distutils. So it’s a far cry from Go or C++ apps built with CMake.

The most common way to build a Python app are pyinstaller and cx_Freeze, with pyinstaller generally being the more solid choice. These nominally spider through all of the import statements in your main script and then copy all the needed .pyc and C-extension libraries into a folder, and then embed a Python interpreter in a executable that then runs your main script. Sometimes however you can run into problems with Python modules that are C-extensions (SciPy being one of the biggest offenders). Furthermore, your client might actually want a Python module in addition to some GUI application, such as in the science and data analysis markets. In this case the executable bundlers aren’t so workable. You may also want to have things like icons, and start menu groups, and an uninstaller, in which case having an installer might be complementary to pyinstaller.

One solution is to bundle a Python virtual environment with an installer. Here I’m going to describe how to use Inno Setup (a free setup utility) to bundle a conda virtual environment. I’m assuming here that you’ve already built a Python module with a setup.py that uses setuptools. You should write __main__.py scripts that can be used as entry points in setuptools. Your metadata dictionary should include some entry points, here I show the syntax for both console and gui entry points:

metadata = dict( 
    # stuff,
    entry_points={
                  "console_scripts": [ "awesomeapp = awesomeapp.__main__:main", ],

                  "gui_scripts": [ "awesomegui = awesomeapp:__main__:main_gui", ],
                  },
    # more stuff,
)

where main and main_gui are functions in said __main__.py that start your app.

A potential zero-th step, which I won’t talk about here, is to convert your Python module to Cython, in order to make it more difficult to reverse engineer your work. This will let you generally compile everything but the __init__.py into a .pyd or .so dynamic library, making reverse engineering far more challenging.

Step 1: Make a minimal dependancy conda environment

First you should install a root Anaconda install from Continuum. This will be a bulky but complete scientific Python install that you can then easily prototype your app with. For distribution we build a new miniconda environment with conda.

conda create -n envAmazingApp python=3.6

We don’t provide a named package (like anaconda) as we want a minimum conda install so we only install the packages we require. conda environments are basically just Python distributions sitting in the env subdirectory, for example,

C:\Anaconda3\envs\envAmazingApp\

When conda finishes, you can install your app with a combination of conda install and pip install. I feel it’s usually a good idea to install with conda first and pip second on Windows. Also you need to have the appropriate MSVC compiler! For Python 3.5/6 this is MSVC2015. The community editions of MSVS will usually suffice unless you need MFC classes, in which case you need the professional edition. There’s a bit more bloat associated with conda solutions versus pip.

One advantage to working with Windows and using wheels is that with Python 3.6 your wheel will be compiled with MSVC2015, whereas manylinux wheels are usually built against old GCC with old GLIB on old CentOS platforms. So Windows wheels typically ends up being faster than manylinux wheels, which is a little novel for Windows users! There’s also Christoph Gohkle’s repository, which has well-optimized Windows wheels typically built with Intel’s Math Kernel Library:

http://www.lfd.uci.edu/~gohlke/pythonlibs/

Generally the smallest distribution I’ve built in this way has been around 50 MB with PyWin32 and PyZMQ. Python 3.7 is itself about 30 MB. Apparently it is possible to build MicroPython on PCs, so this would be one option to further reduce the installer size, but I suspect it’s not worth the trouble

Note: building a virtual env is also good advice for working with pyinstaller. Often the freezers are too aggressive with the packages they include, which can bloat your distribution.

Step 2: Create an Installer

We’re going to use Inno Setup, which is DonationWare, and perfectly servicable.

Here’s an example installation script, amazingapp.iss:

; Script generated by the Inno Script Studio Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!

#define MyAppName "Da Amazing Python App"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "My Amazing Company"
#define MyAppURL "http://www.myamazingcompany.ca"
#define MyScripts "daamazingapp"

[Setup]
; NOTE: The value of AppId uniquely identifies this application.
; Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={<TODO>}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
ArchitecturesAllowed=x86 x64
DefaultDirName={pf}\AmazingApp
DefaultGroupName=AmazingApp
InfoAfterFile=C:\local\AmazingApp\README.rst
OutputDir=C:\local\AmazingApp\installers
OutputBaseFilename=setup
SetupIconFile=C:\local\AmazingApp\installers\icons\amazing.ico
Compression=lzma2/max
;Compression=lzma2/fast
SolidCompression=yes
CompressionThreads=auto
LZMANumBlockThreads=4
LZMAUseSeparateProcess=yes

; For post-install Python processing we must have admin rights
PrivilegesRequired=admin

[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"

[Files]
Source: "C:\Anaconda3\envs\envAmazingApp\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "C:\local\AmazingApp\installers\fix_shebangs.py"; DestDir: "{app}";

; NOTE: Don not use "Flags: ignoreversion" on any shared system files
Source: "C:\local\AmazingApp\ThirdPartyActiveX.ocx"; DestDir: "{sys}"; Flags: onlyifdoesntexist regserver

; Icon files must be explicitely included
Source: "C:\local\AmazingApp\installers\icons\*.ico"; DestDir: "{app}\icons"
[Icons]
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{group}\AmazingApp"; \
  Filename: "cmd"; \
  Parameters: "/k ""{app}\Scripts\amazingapp.exe"""; \
  IconFilename: "{app}\icons\amazing.ico";
Name: "{group}\AmazingApp Emulator"; \
  Filename: "cmd"; \
  Parameters: "/k ""{app}\Scripts\amazingapp.exe"" -emu"; \
  IconFilename: "{app}\icons\amazing.ico";
Name: "{group}\IDLE (Python shell)"; \
  Filename: "{app}\Scripts\idle.exe"; \
  IconFilename: "{app}\icons\idle-icon.ico";

[Run]
; We do not want the user to have the option of avoiding this script, so no 'postinstall; flag
Filename:{app}\python.exe; WorkingDir:{app}; Parameters: "fix_shebangs.py {#MyScripts}"; Flags: runascurrentuser runmaximized

[UninstallDelete]
Type: files; Name: {app}\install.log

I’m doing a few special things here for illustration purposes:

  1. I’m including a third-party OCX Active-X control ThirdPartyActiveX.ocx that will be registered in the Windows registry, simply to show how one registers DLLs.
  2. I have two icons in the start menu group, one which starts the entry point normally, and the other calls the
    entry point with arguments.
  3. I’ve also included an icon to start Idle to give the user an interactive Python console. You could equally setup an icon to start Juypter or IPython. You could even start them with -i -c "import numpy; import mymodule" so pre-requisite are pre-loaded.

Note: I’m running the entry points prefaced with cmd \k for good reason. Python has good error handling, but if you get an unhandled exception it will print it to the screen, exit, and then Windows will immediately close the console window. Users don’t like this; even if you log to a file a lot of people will prefer just take a screen shot (with a cell phone) and email you that. Avoid frustrating your users and let them see the exception info.

The final problem is how to deal with the shebangs that are in your entry-point scripts as generated by setuptools. While Python shebangs are not handled by the OS, they are still by convention absolute paths. This is a problem if you let the user choose the install path! So we have to run a Python script afterwards to de-mangle the shebangs in the entry points:

# This script is intended to fix shebangs in setuptools entry points after installation by 
# InnoSetup.
#
# e.g.
# [Run]
# "C:\Program Files\AwesomeApp\python.exe fix_shebangs.py awesomeapp"
#
# For each argument {arg}, it will search for a file 
#   .\Scripts\{arg}-script.py
# and fix the shebang to point to the correct interpreter.

import os, os.path, sys, inspect, time

if os.name != 'nt':
    raise OSError( 'Fix shebangs only designed for Windows platform Python at present' )

currentDir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
scriptsDir = os.path.join( currentDir, 'Scripts' )
sys.path.insert(0,currentDir) 
sys.path.insert(0,scriptsDir)

# Prefer to log to a file, but if that 
try:
    log = open( os.path.join( currentDir, 'install.log'), 'w' )
except PermissionError:
    class Log(object):
        def __init__(self):
            self.write = print
            
    log = Log()

for script in sys.argv[1:]:
    log.write( 'De-mangling script {}\n'.format(script) )
    
    interp = 'python.exe'
    scriptFile = os.path.join( scriptsDir, script + '-script.py' )
    if not os.path.isfile( scriptFile ):
        # For some reason gui_scripts have the extension .pyw
        interp = 'pythonw.exe'
        scriptFile = os.path.join( scriptsDir, script + '-script.pyw' )
        if not os.path.isfile( scriptFile ):
            log.write( 'Script {} does not exist.\n'.format(script) )
            continue
    
    with open( scriptFile, 'r' ) as sh:
        scriptLines = sh.readlines()
            
    new_shebang = '#!"{}"\n'.format( os.path.join(currentDir, interp )  )
    
    log.write( "New shebang for {}: {}\n".format(script,new_shebang) )
    scriptLines[0] = new_shebang
    # Writing here may require administrator access!
    with open( scriptFile, 'w' ) as sh:
        sh.writelines( scriptLines )

if log.write is print:        
    print( "Couldn't create install.log, so waiting..." )
    time.sleep(10.0)
else:
    log.close()

Step 3: Test on a virgin Windows VirtualBox

Windows pathing being the way it is, I advise you make a VirtualBox image of Windows and run the installer there. Otherwise you might find that things that run on your computer don’t run on other people’s computers, or more trivial problems like missing icons show up. Save yourself that embarrassment.

Comments on reddit (requires Javascript):

Distributing Python apps on Windows with Inno Setup from Python
Robert

Portrait

    Contact: