Get in touch


20.06.2016

Distribute Django Web Apps as Native Desktop Apps

Part 2: Build a browser and bundle it as an app

In part 1 of this workshop we have set up our development environment and can now start coding. Our goal in this section is to build a double-clickable application, which opens an integrated browser and loads an external web page. You can download the code you would otherwise write on your own from here.

Let's create our main file and make it executable. Please make sure your current working directory is inside our freshly created folder and you have activated your virtual Python environment.

touch main.py chmod +x main.py

Please note that the name and the content of this file are very relevant.
This file will be loaded as the starting point of our application, and py2app will copy this file into our Resources folder of our resulting application in clear text form, so everybody will be able to read this file easily.
If you want to hide your code, you might want to add a single line only which imports an external Python file which then runs your application code.
On the other hand, it's pretty easy to decompile py2app bundled applications anyway, so I use py2app to make packaging easier, not to hide sources.
Therefore in the following I will add the code to this file directly.

Now add the following code snippet to this file:

# -*- coding: utf-8 -*-
 import webview 

# Create a resizable webview window with 800x600 dimensions
 webview.create_window("PyBrowse", "https://moosystems.com",
 width=800, height=600, resizable=True, fullscreen=False)

The code is very simple. We tell our Python interpreter to be aware of unicode encodings, in line two we import the module which can build browsers on different operating systems, and in line four we create a browser window, giving it a name, a url to load, the window size, and we make it resizable. As you see, making a browser window open in fullscreen mode is very easy, too. Just set fullscreen to True.

Save the file and run it:

python main.py

This opens a web browser and loads the moosystems web page, which will look like this:

PyBrowse loads moosystems.com

Now that we have a working browser application in place, please close it by clicking on the red button in the upper left corner.

Let's bundle the application, now. Please download this icon file and place it inside our PyBrowse directory. Now create a setup.py file by typing

py2applet --make-setup main.py

Open the resulting setup.py and make it look like this:

from setuptools import setup APP = ['main.py'] OPTIONS = {'argv_emulation': True, 'iconfile': './PyBrowse.icns', 'plist': { 'CFBundleIdentifier': "com.moosystems.pybrowse", 'CFBundleName': "PyBrowse", 'CFBundleVersion': '1001', 'CFBundleShortVersionString': '1.0', 'NSHumanReadableCopyright': 'Copyright 2016 moosystems' } } setup( app=APP, options={'py2app': OPTIONS}, setup_requires=['py2app', ] )

If you compare the code above with the code generated by py2applet, the difference is that we have added an icon and set some metadata which will be added to the app's plist file.

Now we build the app:

python setup.py py2app

If the build process fails with an error message ending like this:

File "~/Documents/PyBrowse/env/lib/python3.4/site-packages/py2app/recipes/virtualenv.py", line 52, in retry_import m = mf.load_module(m.identifier, fp, pathname, stuff) AttributeError: 'ModuleGraph' object has no attribute 'load_module'

Then open the file ~/Documents/PyBrowse/env/lib/python3.4/site-packages/py2app/recipes/virtualenv.py and make the changes according to this page.

Modulegraph, which py2app uses to understand dependencies between modules used in your app, has changed an attribute name, therefore we need to replace

'm = mf.load_module(m.identifier, fp, pathname, stuff)'

in line 52 with:

if hasattr(mf, "load_module"): m = mf.load_module(m.identifier, fp, pathname, stuff) else: m = mf._load_module(m.identifier, fp, pathname, stuff)

Once you have saved these modifications, please rerun 'python setup.py py2app'.

If the build process success, it will finish with the line "Done!". In our working directory you should find a build and a dist folder. Please navigate into the dist folder and double-click on the PyBrowse app.

If this opens an error window like this one:

PyBrowse error message in UI

Then open your Console application and spot the PyBrowse error message:

30/05/16 10:59:54,520 PyBrowse[67245]: Traceback (most recent call last): 30/05/16 10:59:54,520 PyBrowse[67245]: File "/Users/andre/Documents/PyBrowse/dist/PyBrowse.app/Contents/Resources/__boot__.py", line 351, in <module> 30/05/16 10:59:54,520 PyBrowse[67245]: _run() 30/05/16 10:59:54,521 PyBrowse[67245]: File "/Users/andre/Documents/PyBrowse/dist/PyBrowse.app/Contents/Resources/__boot__.py", line 336, in _run 30/05/16 10:59:54,521 PyBrowse[67245]: exec(compile(source, path, 'exec'), globals(), globals()) 30/05/16 10:59:54,521 PyBrowse[67245]: File "/Users/andre/Documents/PyBrowse/dist/PyBrowse.app/Contents/Resources/main.py", line 3, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: import webview 30/05/16 10:59:54,521 PyBrowse[67245]: File "webview/__init__.pyc", line 29, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: File "webview/cocoa.pyc", line 9, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: File "Foundation/__init__.pyc", line 8, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: File "objc/__init__.pyc", line 32, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: File "objc/_bridgesupport.pyc", line 13, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: File "pkg_resources/__init__.pyc", line 46, in <module> 30/05/16 10:59:54,521 PyBrowse[67245]: File "pkg_resources/extern/__init__.pyc", line 60, in load_module 30/05/16 10:59:54,521 PyBrowse[67245]: ImportError: The 'six' package is required; normally this is bundled with this package so if you get this warning, consult the packager of your distribution.

You can see that PyBrowse is complaining that the six package can not be found, though there were no complaints about it when we started the app from the command-line. This happens whenever py2app does not include necessary modules, so let's tell py2app to include this module. Open setup.py again and modify OPTIONS to look like this:

OPTIONS = {'argv_emulation': True, 'iconfile': './PyBrowse.icns', 'includes': ['six'], 'plist': { 'CFBundleIdentifier': "com.moosystems.pybrowse", 'CFBundleName': "PyBrowse", 'CFBundleVersion': '1001', 'CFBundleShortVersionString': '1.0', 'NSHumanReadableCopyright': 'Copyright 2016 moosystems' }}

We have added the line 'includes': ['six'], which tells py2app to include the six module. Do make sure that the six module has been included with our virtual Python environment, too:

pip install six

Rerun the build process and open the resulting app. You will run into more errors, so please install the packaging module into your virtual Python environment:

pip install packaging

and extend the 'includes' section in your setup.py file:

'includes': ['six', 'packaging', 'packaging.version', 'packaging.specifiers', 'packaging.requirements'],

Now the build process should succeed and your app will start once you double-click on it. You will notice that in the menu bar the name of the app shows up but there are no visible menus. Pywebview uses PyObjC to build the browser UI and the menus in the menu bar. If you need more functionality in the menu bar, you can look at ./env/lib/python3.4/site-packages/webview and see how the UI is being built. You might want to fork pywebview if you modify its code.

To close the app you can either hit command-Q or you click on the red button in the upper-left corner. Once you reopen the app, you will find that the app will not be your frontmost app. This is the same with Tk apps, by the way, and can be fixed by adding some code to our main.py. Let's also add some logging to our app, too:

# -*- coding: utf-8 -*- import webview import subprocess import sys import os import logging import logging.handlers # check if we are running as py2app bundle or as a script if getattr(sys, 'frozen', None): base_dir = os.path.realpath( os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) run_as_binary = True else: base_dir = os.path.realpath(os.path.dirname(__file__)) run_as_binary = False # set up logging and app_name if run_as_binary is True: log_file = os.path.join(base_dir, "..", "PyBrowse.log") cherry_access_log = os.path.join(base_dir, "..", "access.log") cherry_error_log = os.path.join(base_dir, "..", "error.log") app_name = "PyBrowse" else: log_file = os.path.join(base_dir, "PyBrowse.log") cherry_access_log = os.path.join(base_dir, "access.log") cherry_error_log = os.path.join(base_dir, "error.log") app_name = "Python" log = logging.getLogger("PyBrowse") log.setLevel(logging.DEBUG) handler = logging.handlers.RotatingFileHandler(log_file, maxBytes=30000000, backupCount=10) handler.setLevel(logging.DEBUG) fmt = logging.Formatter('%(asctime)s - %(message)s') handler.setFormatter(fmt) log.addHandler(handler) # make app show up as frontmost app system_feedback = subprocess.Popen([ "/usr/bin/osascript", "-e", 'tell app \"Finder\" to set frontmost of process \"%s\" to true' % app_name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True).communicate()[0].rstrip().decode("utf-8") # Create a resizable webview window with 800x600 dimensions webview.create_window("PyBrowse", "https://moosystems.com", width=800, height=600, resizable=True, fullscreen=False)

There's lots of new code here, let's see what it does. First we check if we are running as a packaged app or as a script and set the base directory accordingly. Then we set the paths to our log files and set the name of the application as the Finder will see it. This is necessary so that we will be able to bring the app with that name to the front once we start up.

Please note that in this example we place our log files right next to our app. If you want to deploy your app in the real world, you might want to place log files and dynamic data in the user folder or in the /Library or /usr/local/ directory, at least outside your app bundle, so that you can easily update your app without deleting data.

Then we set up logging and bring our application to the front once we start up. Package the app and run it to see if it works.

If this all works fine, we have managed to bundle an app which opens an internal browser and loads an external web page. Let's head over to part 3 and load our own dynamic content, which we bundle with our app.

Part 1: General notes and setup of development environment

Part 3: Add dynamic content using CherryPy

Imprint & Privacy Policy
1