wxPython: Updating Your Application with Esky

Today we’re going to learn about one of wxPython’s newer features: wx.lib.softwareupdate. It was actually added a couple of years ago. What this allows you to do is add update abilities to your software. As far as I can tell, this mixin only allows prompted updates, not silent updates.

Getting Started

It’s built into wxPython 2.9, so you’ll need that if you want to follow along. The software update feature actually uses the Esky project. If you’re on Windows, you’ll also need py2exe. If you’re on Mac, then you’ll need py2app. We’ll be using the code from one of my previous articles for this demonstration. I created two versions of an Image Viewer, so you’ll want to grab that code as well. Note that we’ll be showing how to do this on Windows only!

Once you have the code from the article, you should put each of the files into separate directories. I recommend doing something like this:

TopFolder
  --> imageViewer0.0.1
  --> imageViewer0.1.0

Then make sure that you rename image_viewer2.py to image_viewer.py in the second directory so that script names match. Now we’re ready to actually go over the code.

Adding Update Code to the Initial Release

Our initial release will be based on the code below. I have added the software update bits to, which we’ll look at after the code:

import os
import wx
from wx.lib.softwareupdate import SoftwareUpdate

########################################################################
class PhotoCtrl(wx.App, SoftwareUpdate):
    """
    The Photo Viewer App Class
    """
    #----------------------------------------------------------------------
    def __init__(self, redirect=False, filename=None):
        wx.App.__init__(self, redirect, filename)
        
        BASEURL = "http://127.0.0.1:8000"
        self.InitUpdates(BASEURL, 
                         BASEURL + "/" + 'ChangeLog.txt')
        self.SetAppDisplayName('Image Viewer')
        self.CheckForUpdate()
        
        self.frame = wx.Frame(None, title='Photo Control')
                          
        self.panel = wx.Panel(self.frame)

        self.PhotoMaxSize = 500
        
        self.createWidgets()
        self.frame.Show()
        
    #----------------------------------------------------------------------
    def createWidgets(self):
        instructions = 'Browse for an image'
        img = wx.EmptyImage(240,240)
        self.imageCtrl = wx.StaticBitmap(self.panel, wx.ID_ANY, 
                                         wx.BitmapFromImage(img))
        
        instructLbl = wx.StaticText(self.panel, label=instructions)
        self.photoTxt = wx.TextCtrl(self.panel, size=(200,-1))
        browseBtn = wx.Button(self.panel, label='Browse')
        browseBtn.Bind(wx.EVT_BUTTON, self.onBrowse)
        
        self.mainSizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer = wx.BoxSizer(wx.HORIZONTAL)
        
        self.mainSizer.Add(wx.StaticLine(self.panel, wx.ID_ANY),
                           0, wx.ALL|wx.EXPAND, 5)
        self.mainSizer.Add(instructLbl, 0, wx.ALL, 5)
        self.mainSizer.Add(self.imageCtrl, 0, wx.ALL, 5)
        self.sizer.Add(self.photoTxt, 0, wx.ALL, 5)
        self.sizer.Add(browseBtn, 0, wx.ALL, 5)        
        self.mainSizer.Add(self.sizer, 0, wx.ALL, 5)
        
        self.panel.SetSizer(self.mainSizer)
        self.mainSizer.Fit(self.frame)

        self.panel.Layout()
        
    #----------------------------------------------------------------------
    def onBrowse(self, event):
        """ 
        Browse for file
        """
        wildcard = "JPEG files (*.jpg)|*.jpg"
        dialog = wx.FileDialog(None, "Choose a file",
                               wildcard=wildcard,
                               style=wx.OPEN)
        if dialog.ShowModal() == wx.ID_OK:
            self.photoTxt.SetValue(dialog.GetPath())
        dialog.Destroy() 
        self.onView()

    #----------------------------------------------------------------------
    def onView(self):
        """
        Attempts to load the image and display it
        """
        filepath = self.photoTxt.GetValue()
        img = wx.Image(filepath, wx.BITMAP_TYPE_ANY)
        # scale the image, preserving the aspect ratio
        W = img.GetWidth()
        H = img.GetHeight()
        if W > H:
            NewW = self.PhotoMaxSize
            NewH = self.PhotoMaxSize * H / W
        else:
            NewH = self.PhotoMaxSize
            NewW = self.PhotoMaxSize * W / H
        img = img.Scale(NewW,NewH)

        self.imageCtrl.SetBitmap(wx.BitmapFromImage(img))
        self.panel.Refresh()
        self.mainSizer.Fit(self.frame)
       
#----------------------------------------------------------------------
if __name__ == '__main__':
    app = PhotoCtrl()
    app.MainLoop()

There are only a couple of changes that were needed to make this code work. First we imported SoftwareUpdate from wx.lib.softwareupdate. Next we need to create a sub-class of BOTH wx.App and SoftwareUpdate. Yes, Python supports multiple inheritance. Then in the __init__ constructor, we need to call InitUpdates with a URL of our choice plus that same URL concatenated with ChangeLog.txt. We set the display name of the application and finally we call CheckForUpdate. That’s it! Now we just need to package this up.

You will need to create a setup.py script with the following in it that you will place in the same directory as the initial release script:

#---------------------------------------------------------------------------
# This setup file serves as a model for how to structure your
# distutils setup files for making self-updating applications using
# Esky.  When you run this script use
#
#    python setup.py bdist_esky
#
# Esky will then use py2app or py2exe as appropriate to create the
# bundled application and also its own shell that will help manage
# doing the updates.  See wx.lib.softwareupdate for the class you can
# use to add self-updates to your applications, and you can see how
# that code is used here in the superdoodle.py module.
#---------------------------------------------------------------------------


import sys, os
from esky import bdist_esky
from setuptools import setup

import version


# platform specific settings for Windows/py2exe
if sys.platform == "win32":
    import py2exe
    
    FREEZER = 'py2exe'
    FREEZER_OPTIONS = dict(compressed = 0,
                           optimize = 0,
                           bundle_files = 3,
                           dll_excludes = ['MSVCP90.dll',
                                           'mswsock.dll',
                                           'powrprof.dll', 
                                           'USP10.dll',],
                        )
    exeICON = 'mondrian.ico'
    
                 
# platform specific settings for Mac/py2app
elif sys.platform == "darwin":
    import py2app
    
    FREEZER = 'py2app'
    FREEZER_OPTIONS = dict(argv_emulation = False, 
                           iconfile = 'mondrian.icns',
                           )
    exeICON = None
    

    
# Common settings    
NAME = "wxImageViewer"
APP = [bdist_esky.Executable("image_viewer.py", 
                             gui_only=True,
                             icon=exeICON,
                             )]
DATA_FILES = [ 'mondrian.ico' ]
ESKY_OPTIONS = dict( freezer_module     = FREEZER,
                     freezer_options    = FREEZER_OPTIONS,
                     enable_appdata_dir = True,
                     bundle_msvcrt      = True,
                     )
    

# Build the app and the esky bundle
setup( name       = NAME,
       scripts    = APP,
       version    = version.VERSION,
       data_files = DATA_FILES,
       options    = dict(bdist_esky=ESKY_OPTIONS),
       )

You’ll also need a version.py file with the following:

VERSION='0.0.1'

Now you’re ready to actually create the executable. Go into the command line and navigate to the folder in which you put these files. I have also put a couple of icon files in my folder too, which you’ll find at the end of this article in the Downloads section. You’ll want those as the setup.py script expects to find them. Okay, so now we need to create the distribution. Type in the following in your command shell:


python setup.py bdist_esky

This assumes that you have Python in your path. If you don't, you'll want to Google how to do that. After you run this commend, you'll see a whole bunch of output. If everything goes well, you'll end up with two new sub-folders: build and dist. We don't really care about the build folder. The dist folder should have one file in it, named something like this: wxImageViewer-0.0.1.win32.zip

To make things simple, you should create a downloads folder to copy that into. Now we just need to do the same thing to the new release. We'll be looking at that next.

Preparing the New Release

Here's the code for the new release:

# ----------------------------------------
# image_viewer2.py
#
# Created 03-20-2010
#
# Author: Mike Driscoll
# ----------------------------------------

import glob
import os
import wx
from wx.lib.pubsub import Publisher
from wx.lib.softwareupdate import SoftwareUpdate

########################################################################
class ViewerPanel(wx.Panel):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, parent):
        """Constructor"""
        wx.Panel.__init__(self, parent)
        
        width, height = wx.DisplaySize()
        self.picPaths = []
        self.currentPicture = 0
        self.totalPictures = 0
        self.photoMaxSize = height - 200
        Publisher().subscribe(self.updateImages, ("update images"))

        self.slideTimer = wx.Timer(None)
        self.slideTimer.Bind(wx.EVT_TIMER, self.update)
        
        self.layout()
        
    #----------------------------------------------------------------------
    def layout(self):
        """
        Layout the widgets on the panel
        """
        
        self.mainSizer = wx.BoxSizer(wx.VERTICAL)
        btnSizer = wx.BoxSizer(wx.HORIZONTAL)
        
        img = wx.EmptyImage(self.photoMaxSize,self.photoMaxSize)
        self.imageCtrl = wx.StaticBitmap(self, wx.ID_ANY, 
                                         wx.BitmapFromImage(img))
        self.mainSizer.Add(self.imageCtrl, 0, wx.ALL|wx.CENTER, 5)
        self.imageLabel = wx.StaticText(self, label="")
        self.mainSizer.Add(self.imageLabel, 0, wx.ALL|wx.CENTER, 5)
        
        btnData = [("Previous", btnSizer, self.onPrevious),
                   ("Slide Show", btnSizer, self.onSlideShow),
                   ("Next", btnSizer, self.onNext)]
        for data in btnData:
            label, sizer, handler = data
            self.btnBuilder(label, sizer, handler)
            
        self.mainSizer.Add(btnSizer, 0, wx.CENTER)
        self.SetSizer(self.mainSizer)
            
    #----------------------------------------------------------------------
    def btnBuilder(self, label, sizer, handler):
        """
        Builds a button, binds it to an event handler and adds it to a sizer
        """
        btn = wx.Button(self, label=label)
        btn.Bind(wx.EVT_BUTTON, handler)
        sizer.Add(btn, 0, wx.ALL|wx.CENTER, 5)
        
    #----------------------------------------------------------------------
    def loadImage(self, image):
        """"""
        image_name = os.path.basename(image)
        img = wx.Image(image, wx.BITMAP_TYPE_ANY)
        # scale the image, preserving the aspect ratio
        W = img.GetWidth()
        H = img.GetHeight()
        if W > H:
            NewW = self.photoMaxSize
            NewH = self.photoMaxSize * H / W
        else:
            NewH = self.photoMaxSize
            NewW = self.photoMaxSize * W / H
        img = img.Scale(NewW,NewH)

        self.imageCtrl.SetBitmap(wx.BitmapFromImage(img))
        self.imageLabel.SetLabel(image_name)
        self.Refresh()
        Publisher().sendMessage("resize", "")
        
    #----------------------------------------------------------------------
    def nextPicture(self):
        """
        Loads the next picture in the directory
        """
        if self.currentPicture == self.totalPictures-1:
            self.currentPicture = 0
        else:
            self.currentPicture += 1
        self.loadImage(self.picPaths[self.currentPicture])
        
    #----------------------------------------------------------------------
    def previousPicture(self):
        """
        Displays the previous picture in the directory
        """
        if self.currentPicture == 0:
            self.currentPicture = self.totalPictures - 1
        else:
            self.currentPicture -= 1
        self.loadImage(self.picPaths[self.currentPicture])
        
    #----------------------------------------------------------------------
    def update(self, event):
        """
        Called when the slideTimer's timer event fires. Loads the next
        picture from the folder by calling th nextPicture method
        """
        self.nextPicture()
        
    #----------------------------------------------------------------------
    def updateImages(self, msg):
        """
        Updates the picPaths list to contain the current folder's images
        """
        self.picPaths = msg.data
        self.totalPictures = len(self.picPaths)
        self.loadImage(self.picPaths[0])
        
    #----------------------------------------------------------------------
    def onNext(self, event):
        """
        Calls the nextPicture method
        """
        self.nextPicture()
    
    #----------------------------------------------------------------------
    def onPrevious(self, event):
        """
        Calls the previousPicture method
        """
        self.previousPicture()
    
    #----------------------------------------------------------------------
    def onSlideShow(self, event):
        """
        Starts and stops the slideshow
        """
        btn = event.GetEventObject()
        label = btn.GetLabel()
        if label == "Slide Show":
            self.slideTimer.Start(3000)
            btn.SetLabel("Stop")
        else:
            self.slideTimer.Stop()
            btn.SetLabel("Slide Show")
        
        
########################################################################
class ViewerFrame(wx.Frame):
    """"""

    #----------------------------------------------------------------------
    def __init__(self):
        """Constructor"""
        wx.Frame.__init__(self, None, title="Image Viewer")
        panel = ViewerPanel(self)
        self.folderPath = ""
        Publisher().subscribe(self.resizeFrame, ("resize"))
        
        self.initToolbar()
        self.sizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer.Add(panel, 1, wx.EXPAND)
        self.SetSizer(self.sizer)
        
        self.Show()
        self.sizer.Fit(self)
        self.Center()
        
        
    #----------------------------------------------------------------------
    def initToolbar(self):
        """
        Initialize the toolbar
        """
        self.toolbar = self.CreateToolBar()
        self.toolbar.SetToolBitmapSize((16,16))
        
        open_ico = wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_TOOLBAR, (16,16))
        openTool = self.toolbar.AddSimpleTool(wx.ID_ANY, open_ico, "Open", "Open an Image Directory")
        self.Bind(wx.EVT_MENU, self.onOpenDirectory, openTool)
        
        self.toolbar.Realize()
        
    #----------------------------------------------------------------------
    def onOpenDirectory(self, event):
        """
        Opens a DirDialog to allow the user to open a folder with pictures
        """
        dlg = wx.DirDialog(self, "Choose a directory",
                           style=wx.DD_DEFAULT_STYLE)
        
        if dlg.ShowModal() == wx.ID_OK:
            self.folderPath = dlg.GetPath()
            print self.folderPath
            picPaths = glob.glob(self.folderPath + "\\*.jpg")
            print picPaths
        Publisher().sendMessage("update images", picPaths)
        
    #----------------------------------------------------------------------
    def resizeFrame(self, msg):
        """"""
        self.sizer.Fit(self)
        
########################################################################
class ImageApp(wx.App, SoftwareUpdate):
    """"""

    #----------------------------------------------------------------------
    def OnInit(self):
        """Constructor"""
        BASEURL = "http://127.0.0.1:8000"
        self.InitUpdates(BASEURL, 
                         BASEURL + 'ChangeLog.txt')
        self.CheckForUpdate()
        frame = ViewerFrame()
        self.SetTopWindow(frame)
        self.SetAppDisplayName('Image Viewer')
        return True
    
#----------------------------------------------------------------------
if __name__ == "__main__":
    app = wx.PySimpleApp()
    frame = ViewerFrame()
    app.MainLoop()

The main thing to look at here is the last class, ImageApp. Here we do almost exactly the same thing as we did in the previous example, except this time we used wx.App's OnInit() method instead of __init__. There isn't much difference between the two, but I thought you might need to see both versions as you certainly well out in the wild.

We now need to take a look at this version's setup.py as it is a little different:

#---------------------------------------------------------------------------
# This setup file serves as a model for how to structure your
# distutils setup files for making self-updating applications using
# Esky.  When you run this script use
#
#    python setup.py bdist_esky
#
# Esky will then use py2app or py2exe as appropriate to create the
# bundled application and also its own shell that will help manage
# doing the updates.  See wx.lib.softwareupdate for the class you can
# use to add self-updates to your applications, and you can see how
# that code is used here in the superdoodle.py module.
#---------------------------------------------------------------------------


import sys, os
from esky import bdist_esky
from setuptools import setup

import version


# platform specific settings for Windows/py2exe
if sys.platform == "win32":
    import py2exe
    includes = ["wx.lib.pubsub.*", 
                "wx.lib.pubsub.core.*", 
                "wx.lib.pubsub.core.kwargs.*"]
    
    FREEZER = 'py2exe'
    FREEZER_OPTIONS = dict(compressed = 0,
                           optimize = 0,
                           bundle_files = 3,
                           dll_excludes = ['MSVCP90.dll',
                                           'mswsock.dll',
                                           'powrprof.dll', 
                                           'USP10.dll',],
                           includes = includes
                        )
    exeICON = 'mondrian.ico'
    
                 
# platform specific settings for Mac/py2app
elif sys.platform == "darwin":
    import py2app
    
    FREEZER = 'py2app'
    FREEZER_OPTIONS = dict(argv_emulation = False, 
                           iconfile = 'mondrian.icns',
                           )
    exeICON = None
    

    
# Common settings    
NAME = "wxImageViewer"
APP = [bdist_esky.Executable("image_viewer.py", 
                             gui_only=True,
                             icon=exeICON,
                             )]
DATA_FILES = [ 'mondrian.ico' ]

ESKY_OPTIONS = dict( freezer_module     = FREEZER,
                     freezer_options    = FREEZER_OPTIONS,
                     enable_appdata_dir = True,
                     bundle_msvcrt      = True,
                     )
    

# Build the app and the esky bundle
setup( name       = NAME,
       scripts    = APP,
       version    = version.VERSION,
       data_files = DATA_FILES,
       options    = dict(bdist_esky=ESKY_OPTIONS)
       )

This second script uses wxPython's pubsub. However, py2exe won't pick up on that by itself, so you have to tell it explicitly to grab the pubsub parts. You do this in the includes section, near the top of the script.

Don't forget to make sure that your version.py file has a higher release value than the original or we won't be able to update. Here's what I put in mine:

VERSION='0.1.0'

Now do the same command line magic as before, except this time do it in your updated release directory: python setup.py bdist_esky

Copy the zip file to your downloads folder. Now we just need to serve these files on your computer's localhost. To do that navigate into your downloads folder via the command line and run the following command:


python -m SimpleHTTPServer

Python will now run a little HTTP server that serves those files. If you go to http://127.0.0.1:8000 in your web browser, you'll see it for yourself. Now we're ready to do the upgrading process!

Updating Your Program

Make sure you unzip the first version of the image viewer somewhere on your machine. Then run the file called image_viewer.exe. If everything goes as planned, you'll see the following:

wxUpdate

Go ahead an apply the update and you'll be asked the restart the application:

wxUpdate2

It should restart and you'll get the new image viewer interface. I noticed that when I closed the application, I received an error which turned out to be a Deprecation Warning. You can ignore that or if you want something to do, you can import the warnings module and suppress that.

Wrapping Up

At this point, you're ready for the big time. You can also use AutoCheckForUpdate instead of CheckForUpdate and pass it length of days between checks so you won't always be phoning home every time you open the application. Or you might want to just put the CheckForUpdate function into an event handler that the user triggers. A lot of applications do this where the user has to go into the menu system and press the "Check for updates" menu item. Use your imagination and start hacking! There's also another project called goodasnew that seems to be a competitor of Esky that you might want to check out. It's not integrated into wxPython right now, but it might be a viable option nonetheless.

Finally, if you'd like to see another example of this, check out the wxPython 2.9 version of the docs and demos package. In there you'll find a samples folder and inside of that you'll see a doodle folder. That has another example of software update in it. Good luck!

Additional Reading

Downloads

5 thoughts on “wxPython: Updating Your Application with Esky”

  1. Kevin Dahlhausen

    Wow, that’s nice. Thanks for creating the tutorial. I am curious as to how windows security reacts to this if the app is in Program Files. Will be fun to check out.

  2. Any specific format for ChangeLog.txt? When I run first example I always get message “You are already running newest version of ImageViewer”

  3. It was indeed connection issue. File/directory index was disabled on server.

    Thanks for your help

Comments are closed.