|
| 1 | +# Distributable Applications |
| 2 | + |
| 3 | +What you have now is a working application on your computer. The problem that |
| 4 | +you face is how to distribute this application to your colleagues so that they |
| 5 | +can also use your work. |
| 6 | + |
| 7 | +If they are comfortable with Python and tools like `git` and `pip` you may be |
| 8 | +able to just give a list of commands to download the code, install an |
| 9 | +environment and run the application. |
| 10 | + |
| 11 | +But a major advantage of GUI applications is that they are intended to be |
| 12 | +accessible by users who are not as knowledgeable about coding. For this reason |
| 13 | +it's desirable to be able to provide them with simple tools which either |
| 14 | +install an application and its environment, or which look and behave like |
| 15 | +ordinary applications on their operating system. |
| 16 | + |
| 17 | +A number of solutions are available for this sort of operation, including |
| 18 | +some commercial solutions from Anaconda and Enthought. Tools which you might |
| 19 | +consider using include: |
| 20 | + |
| 21 | +- [PyOxidizer](https://pyoxidizer.readthedocs.io/en/stable/), which |
| 22 | + is comprehensive but complex and written in Rust. |
| 23 | +- [Py2App](https://py2app.readthedocs.io/en/latest/), which is aimed at |
| 24 | + MacOS applications specifically |
| 25 | +- [BeeWare](https://beeware.org/)'s [Briefcase](https://briefcase.readthedocs.io/en/latest/) |
| 26 | + which is new and somewhat incomplete, but aims to be deployable on all |
| 27 | + platforms including iOS and Android. |
| 28 | + |
| 29 | +In this section we will use [PyInstaller](https://pyinstaller.org/en/stable/) |
| 30 | +because it works on all major desktop platforms and is fairly mature. |
| 31 | + |
| 32 | +## PyInstaller Basics |
| 33 | + |
| 34 | +At its simplest, using PyInstaller is just a matter of installing pyinstaller |
| 35 | +with `pip`: |
| 36 | +``` |
| 37 | +pip install -U pyinstaller |
| 38 | +``` |
| 39 | +changing to the directory of your program, and running |
| 40 | +``` |
| 41 | +pysinstaller my_script.py |
| 42 | +``` |
| 43 | +PyInstaller will create an application file (.exe if on Windows, executable if |
| 44 | +on POSIX systems) and place it in a `dist/` folder |
| 45 | +next to your application. You can run this executable as a command from the |
| 46 | +command-line, or by finding the icon in your OS file browser and opening it |
| 47 | +that way. |
| 48 | + |
| 49 | +PyInstaller tries to analyse your code and only include modules that it knows |
| 50 | +that your application will use, to make the resulting application file as small |
| 51 | +as possible. This is magical, and as with all magical things there are a lot |
| 52 | +of options and things to tweak to make sure that the magic works. |
| 53 | + |
| 54 | +### OS and Environment |
| 55 | + |
| 56 | +PyInstaller must be run in a working Python environment containing your |
| 57 | +application code and dependencies. This unfortunately means that you can't |
| 58 | +build for a different OS or CPU architecture; indeed in some cases you may |
| 59 | +not be able to build an application for an earlier version of the OS you use. |
| 60 | +If you have users on many different systems, you may need to have a collection |
| 61 | +of virtual machines (or even physical machines) configured appropriately for |
| 62 | +performing the builds. |
| 63 | + |
| 64 | +PyInstaller has some additional options for Windows and MacOS to help support |
| 65 | +the particular idiosyncracies of each. |
| 66 | + |
| 67 | +### Single Directory vs. Single File |
| 68 | + |
| 69 | +PyInstaller gives you a choice between building an application as an |
| 70 | +executable plus auxiliary files as a single directory, or as a single |
| 71 | +executable file. While the single file is nicer, getting it to work can be |
| 72 | +more difficult. For simplicity, we'll use the default single directory |
| 73 | +approach for this tutorial. You can use zip or a similar utility to bundle |
| 74 | +this for easier distribution: most users are comfortable with unzipping |
| 75 | +a bundle that they have been given before running it. |
| 76 | + |
| 77 | +### Windowed vs. Console |
| 78 | + |
| 79 | +On Windows and MacOS, PyInstaller needs to be told whether the application |
| 80 | +needs a text-based terminal window available for input or displaying things |
| 81 | +to the user (even if it opens a GUI) or a purely windowed application. |
| 82 | +These are controlled by the `--console` and `--windowed` command-line options. |
| 83 | + |
| 84 | +## Common Problems |
| 85 | + |
| 86 | +Because Python is a very dynamic language, it can be difficult or impossible |
| 87 | +for PyInstaller to work out exactly what it needs to include in the |
| 88 | +application bundle. |
| 89 | + |
| 90 | +### Data Files |
| 91 | + |
| 92 | +It's particularly common for GUI applications to have additional files that |
| 93 | +they need to operate. The most common are image files for icons and logos, |
| 94 | +but can include things like HTML files containing documentation, data files, |
| 95 | +or trained machine learning model files. |
| 96 | + |
| 97 | +PyInstaller has no way of knowing if any non-Python files are needed, and so |
| 98 | +it needs to be told. This includes any such files needed by libraries that |
| 99 | +your application uses. |
| 100 | + |
| 101 | +### Dynamic Imports |
| 102 | + |
| 103 | +Python is a very dynamic language and can import modules by methods other than |
| 104 | +the standard `import` statement. This is commonly used by libraries that need |
| 105 | +to decide what code to use based on the environment that they find themselves |
| 106 | +in. For example libraries like Pyface, TraitsUI and Matplotlib will look at |
| 107 | +environment variables and/or try importing GUI library dependencies like Qt to |
| 108 | +determine which one they should use. This is very convenient for people who |
| 109 | +are writing and distributing scripts, but it makes it difficult for |
| 110 | +PyInstaller to perform its import analysis. |
| 111 | + |
| 112 | +### Entry Points |
| 113 | + |
| 114 | +Related to dynamic importing, many Python libraries use "entry points" to |
| 115 | +advertise capabilities to other code. You most likely have seen this in a |
| 116 | +package setup file where you may have seen or used lines like: |
| 117 | +``` |
| 118 | +entry_points={ |
| 119 | + 'console_scripts': [ |
| 120 | + 'my_script = my_package.my_script:main', |
| 121 | + ], |
| 122 | +} |
| 123 | +``` |
| 124 | +which advertise that the package has a command-line script `my_script` that |
| 125 | +can be run from the `main` function in `my_package.my_script` and Python tools |
| 126 | +will ensure that these are made available when you install them into a Python |
| 127 | +environment. However they are a much more general mechanism which can be used |
| 128 | +for building general "plugin" capabilities for Python libraries. |
| 129 | + |
| 130 | +Amir Rachum has a [good blog post](https://amir.rachum.com/blog/2017/07/28/python-entry-points/) |
| 131 | +from a few years back that explains why you might use or care about entry |
| 132 | +points. |
| 133 | + |
| 134 | +Entry points used to be part of the `setuptools` library, but since Python 3.8 |
| 135 | +they are now available via the |
| 136 | +[imporlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) |
| 137 | +standard library module. |
| 138 | + |
| 139 | +Again, the problem for PyInstaller is that it can't detect use of code that |
| 140 | +comes from entry points, but in addition PyInstaller doesn't expose the entry |
| 141 | +points for libraries that it wraps by default. So if you include code that |
| 142 | +expects to load capabilities via this mechanism they will fail even if the |
| 143 | +required code is packaged unless you tell PyInstaller about the entry points. |
| 144 | + |
| 145 | +## Spec Files and Hook Files |
| 146 | + |
| 147 | +While many of these problems can be corrected via the use of appropriate |
| 148 | +command-line options, once you have any level of complexity you will want to |
| 149 | +be putting these into something more repeatable and editable. PyInstaller |
| 150 | +has two mechanisms for this: |
| 151 | + |
| 152 | +- `.spec` files, which are Python files which hold the build instructions for |
| 153 | + an application as a whole |
| 154 | +- `hook-` files, which are Python files which hold information about a |
| 155 | + particular package and how it should work with PyInstaller |
| 156 | + |
| 157 | +### Spec Files |
| 158 | + |
| 159 | +When you run `pysinstaller my_script.py`, PyInstaller first creates a |
| 160 | +corresponding `my_script.spec` file using the command-line options passed in |
| 161 | +and runs the code in that file. You can also create a `.spec` file using the |
| 162 | +`pyi-makespec` commands. Once you have a `.spec` file, you can instead run |
| 163 | +``` |
| 164 | +pyinstaller my_spec.spec |
| 165 | +``` |
| 166 | +and it will use the options specified there. If you used the `.spec` file |
| 167 | +created automatically, it is probably a good idea to rename it so it doesn't |
| 168 | +accidentally get overwritten if you run `pysinstaller my_script.py` again. |
| 169 | + |
| 170 | +The contents of the file might look something like this (depending on the |
| 171 | +options used to generate it): |
| 172 | +``` |
| 173 | +block_cipher = None |
| 174 | +a = Analysis( |
| 175 | + ['my_script.py'], |
| 176 | + pathex=['/Developer/PItests/minimal'], |
| 177 | + binaries=None, |
| 178 | + datas=None, |
| 179 | + hiddenimports=[], |
| 180 | + hookspath=None, |
| 181 | + runtime_hooks=None, |
| 182 | + excludes=None, |
| 183 | + cipher=block_cipher, |
| 184 | +) |
| 185 | +pyz = PYZ( |
| 186 | + a.pure, |
| 187 | + a.zipped_data, |
| 188 | + cipher=block_cipher, |
| 189 | +) |
| 190 | +exe = EXE(pyz,... ) |
| 191 | +coll = COLLECT(...) |
| 192 | +``` |
| 193 | +Most of this you can ignore for simple usage, but there are a few things that |
| 194 | +you can use to fix the problems listed above: |
| 195 | + |
| 196 | +- adding data files: the `datas` argument to the `Analysis` function expects |
| 197 | + a list of `(source, dest)` tuples that tell PyInstaller to include the |
| 198 | + file(s) at the `source` path in your code in the directory specified by |
| 199 | + `dest` in your application. This understands basic "glob"-style wildcards. |
| 200 | + |
| 201 | + Example: `datas=[("docs/build/html", "documentation"), ("*.txt", ".")]` |
| 202 | + would: |
| 203 | + |
| 204 | + - take the `html` folder as typically built by Sphinx and add it into your |
| 205 | + application distribution as a subdirectory called "documentation". |
| 206 | + |
| 207 | + - take all files with the `.txt` suffix in the main script directory and add |
| 208 | + them to the top-level application directory alongside the executable. |
| 209 | + |
| 210 | +- adding dynamic imports: the `hiddenimports` argument expects a list of |
| 211 | + additional module names to be added to the modules packaged into the |
| 212 | + application. |
| 213 | + |
| 214 | + Example: `hiddenimports=["my_package.editors.qt"]` would add the |
| 215 | + `my_package.editors.qt` module and everything that imports to the packaged |
| 216 | + set of modules. |
| 217 | + |
| 218 | +- adding binary dependencies: PyInstaller is usually fairly good about |
| 219 | + detecting and adding Python C extensions, but if it fails to correctly find |
| 220 | + the extension (or its dependencies) you can use the `binaries` argument |
| 221 | + to supply a list of DLLs or folders containing DLLs (or the OS-specific |
| 222 | + equivalents) that you want added to the application. These are specified |
| 223 | + as `(source, dest)` pairs as for data files. |
| 224 | + |
| 225 | +- specifying a list of additional places to look for hook files with the |
| 226 | + `hookspath` argument. |
| 227 | + |
| 228 | +Manually listing all of these files can be tedious, and so there are several |
| 229 | +utility methods that are available to help populate these lists. |
| 230 | + |
| 231 | +### Hook files |
| 232 | + |
| 233 | +Hook files are similar to `.spec` files, but are instead Python files that |
| 234 | +have a name in the pattern `hook-package.name.py` and which are expected to |
| 235 | +provide particular information that `package.name` needs to correctly work in |
| 236 | +a PyInstaller application. The idea is that these can be defined once for a |
| 237 | +given library and then shared by all the applications which use this library. |
| 238 | + |
| 239 | +PyInstaller comes with built-in support for some common libraries which |
| 240 | +require additional support, such as Matplotlib: you should not need to do |
| 241 | +any additional work to build an application which used matplotlib in a |
| 242 | +standard way, for example. Additionally the |
| 243 | +[PyInstaller hooks repository](https://github.com/pyinstaller/pyinstaller-hooks-contrib) |
| 244 | +has additional community-contributed hook files for popular packages, which |
| 245 | +are `pip`-installable as `pyinstaller-hooks-contrib`. |
| 246 | + |
| 247 | +However, if you are the author of, or want to use, a library that needs |
| 248 | +additional support, then writing a hookfile may be easier than adding things |
| 249 | +to a `.spec` file. |
| 250 | + |
| 251 | +Hook files are expected to populate certain global variables in the module |
| 252 | +with appropriate values: |
| 253 | + |
| 254 | +- `datas`: a list of data files in `(source, dest)` form as described above. |
| 255 | + |
| 256 | +- `hiddenimports`: a list of additional modules to include in the package. |
| 257 | + |
| 258 | +- `binaries`: a list of binary files to include in `(source, dest)` form as |
| 259 | + described above. |
| 260 | + |
| 261 | +### `PyInstaller.utils.hooks` |
| 262 | + |
| 263 | +Specifying all of the extra files to include can be tedious and error-prone, |
| 264 | +particularly as a library or application change over time. PyInstaller has |
| 265 | +some utility files which make it easier to write `.spec` and hook files. Most |
| 266 | +of these can be found in the `PyInstaller.utils.hooks` module, which can be |
| 267 | +imported in a `.spec` or hook file as you would for a normal Python file. |
| 268 | + |
| 269 | +- `collect_data_files`: In the simplest form, you pass this the name of a |
| 270 | + Python package and it will generate a list of `(source, dest)` paths for all |
| 271 | + non-Python files in the package, suitable for inclusion in a `datas` list. |
| 272 | + |
| 273 | +- `collect_submodules`: This will create a list of all submodules of a given |
| 274 | + module in a form suitable for use with the `hiddenimports` list. Submodules |
| 275 | + will be included whether or not they are imported. |
| 276 | + |
| 277 | +- `collect_entry_point`: This inspects the given entry point and returns a |
| 278 | + list of `datas` and a list of `hiddenimports` that are needed to support the |
| 279 | + use of that entry point. |
| 280 | + |
| 281 | +- `collect_dynamic_libs`: This finds all DLLs included in a given package. |
| 282 | + |
| 283 | +## Grace Notes |
| 284 | + |
| 285 | +By default on Windows and Mac OS the application's desktop icon will be the |
| 286 | +default PyInstaller icon. While this is fine in development, it is useful for |
| 287 | +users to have a distinct icon on the desktop. This can be provided using the |
| 288 | +`--icon` command-line option. It can also be specified in a `.spec` file. |
| 289 | + |
| 290 | +For MacOS in particular you can include additional information for the |
| 291 | +`.plist` file inside a Mac app bundle. |
| 292 | + |
| 293 | +## Distributing ETS Applications |
| 294 | + |
| 295 | +The ETS libraries require the use of a number of these tools to build |
| 296 | +applications with PyInstaller. |
| 297 | + |
| 298 | +- many of the libraries have data files, |
| 299 | + |
| 300 | +- dynamic importing is used, particularly to choose between Qt and Wx backend |
| 301 | + implementations, |
| 302 | + |
| 303 | +- availability of toolkits and other functionality are advertised via entry |
| 304 | + points. |
| 305 | + |
| 306 | +The easiest way to overcome these is to simply use the `collect_...` methods |
| 307 | +to gather everything from the top-level package. For example, the following |
| 308 | +code will gather everything that is needed for TraitsUI: |
| 309 | +``` |
| 310 | +from PyInstaller.utils.hooks import ( |
| 311 | + collect_data_files, collect_entry_point, collect_submodules |
| 312 | +) |
| 313 | +
|
| 314 | +data, hiddenimports = collect_entry_point("traitsui.toolkits") |
| 315 | +data += collect_data_files("traitsui") |
| 316 | +hiddenimports += collect_submodules("traitsui") |
| 317 | +``` |
| 318 | + |
| 319 | +This will include code that your application does not use, but it is unlikely |
| 320 | +that the extra size of the resulting application from this will pose a |
| 321 | +problem. |
| 322 | + |
| 323 | +This directory includes hook files suitable for use with the core ETS |
| 324 | +libraries. |
0 commit comments