Get in touch


26.04.2016

Double-click on files in Finder to open them in your Python and Tk application

Bind your Python and Tk app to double-click events in the Finder

If you want to create an OS X application in Python and want to open documents in your app by double-clicking on them in the Finder, this article is for you.

The basics

No matter if you create an application with a graphical user interface or if you build a pure command-line app, you need to make your Python application an OS X app bundle.

One of the solutions to use for that is py2app.

Let's start with a simple Python file called odoc_demo.py, which holds the following content:

#!/usr/bin/env python # -*- coding: utf-8 -*- """This piece of code shows how to accept 'Open Document' Apple Events in your Python / Tk application.""" import os import sys import logging import logging.handlers # configure logging home_dir = os.path.expanduser('~') log_file = os.path.join(home_dir, "Library/Logs/com.moosystems.odoc_demo.log") log = logging.getLogger("main") 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) # callback which gets invoked when files or folders are sent to our app: def doOpenFile(*args): for f in args: if os.path.isdir(f): log.info("'%s' is a directory." % f) elif os.path.isfile(f): log.info("'%s' is a file." % f) else: log.info("'%s' doesn't exist." % f) # when the app starts up, check for command-line arguments: for f in sys.argv[1:]: doOpenFile(f)

Hint: in the example we use Python 2.7.6, which is pre-installed on OS X 10.10. We are also going to use the Tk version which comes with OS X. The example works with Python 3.4.2, too. Simply replace #!/usr/bin/env python with #!/usr/bin/env python3 to use Python 3 instead. Ah, Of course you need to install Python 3 first. Grab it from here.

As you can see, in the script we first set up logging, so that we receive some output in a log file.

Please note that the log file resides in the user's Log directory. This is great for applications which run with user permissions and where users on the local machine need to be completely separated.

In case you want to use a central log file for all local users, please feel free to change the log file path.

Then we create a callback which checks if given input points to files or folders, and we send any command-line arguments to this handler.

If we invoke this piece of code like this:

./odoc_demo.py ../Add2Collection.mov hello ../odoc_demo

and tail the log file:

tail -f ~/Library/Logs/com.moosystems.odoc_demo.log[/code]

we receive output like this:

2014-12-18 10:10:35,087 - '../Add2Collection.mov' is a file. 2014-12-18 10:10:35,087 - 'hello' doesn't exist. 2014-12-18 10:10:35,087 - '../odoc_demo' is a directory.

Now let's install py2app:

pip install -U py2app

If you are using Python 3, use

pip3 install -U py2app

instead. After that invoke

py2applet --make-setup odoc_demo.py

which creates a setup.py file with the following content:

""" This is a setup.py script generated by py2applet Usage: python setup.py py2app """ from setuptools import setup APP = ['odoc_demo.py'] DATA_FILES = [] OPTIONS = {'argv_emulation': True} setup( app=APP, data_files=DATA_FILES, options={'py2app': OPTIONS}, setup_requires=['py2app'], )

In this file you configure how your later OS X app will behave. The line

OPTIONS = {'argv_emulation': True}

tells py2app that the resulting OS X App will accept arguments. This is what sends file and directory paths to our app when you double click a file or drag it onto your app's icon in the Finder or in the Dock. To make OS X aware, that our app accepts files of certain types, we need to add a plist file to our app. Let's say we want our app to react on files with the file name suffix ".odoc-demo".

Then we need to edit our setup.py file to look like this:

""" This is a setup.py script generated by py2applet Usage: python setup.py py2app """ from setuptools import setup APP = ['odoc_demo.py'] DATA_FILES = [] OPTIONS = {'argv_emulation': True, 'plist': { 'CFBundleDocumentTypes': [{ 'CFBundleTypeName': "File suffix of my app's documents", 'CFBundleTypeRole': "Editor", 'LSHandlerRank': "Owner", 'LSItemContentTypes': ["com.moosystems.odoc-demo"], }], 'UTExportedTypeDeclarations': [{ 'UTTypeConformsTo': ["public.data"], 'UTTypeIdentifier': "com.moosystems.odoc-demo", 'UTTypeDescription': "File suffix of my app's documents", 'UTTypeTagSpecification': {'public.filename-extension': "odoc-demo"} }], 'CFBundleIdentifier': "com.moosystems.odoc-demo", 'CFBundleName': "odoc-demo" } } setup( app=APP, data_files=DATA_FILES, options={'py2app': OPTIONS}, setup_requires=['py2app'], )

The main things we do here is that we declare a new document type to OS X, which happens in the section 'UTExportedTypeDeclarations'.

Basically we register the file name suffix "odoc-demo" in the operating system as soon as we generate our app and move it to OS X's Application folder (depending on the exact version of your operating system, the suffix is registered no matter where you place our resulting app).

Then we tell OS X that our app can read and write this kind of document. This can be found in the section 'CFBundleDocumentTypes'.

Read more about registering document types and relating apps to them at Uniform Type Identifiers Overview.

You can add an icon to your app by adding this to options:

'iconfile': 'logo.icns'

logo.icns needs to be a valid icon file in the same path like our current working directory.

Now let's generate the OS X app bundle:

python setup.py py2app

This creates a folder named "dist" in your current working directory, which includes our OS X app "odoc-demo.app".

Now rename a file on your Mac so that its name ends with ".odoc-demo" and double-click on that file.

OS X will show you this warning:

WarningDuringFirstStart

This is because your app starts up the first time. Click "Open" and you will never get asked again.

In the logs you will find something like

2014-12-18 11:05:32,644 - '/Users/andre/Desktop/test.odoc-demo' is a file.

The great thing is, if your app processes a file quickly and then quits, this is all you need to do to accept documents sent by the Finder.

Opening documents while your app is already running

Now imagine you create an app, which is supposed to run very long and should still accept "open document" commands while it's running.

The mechanism we used with py2app to hand over arguments to our app only works when we start our app, so this won't help us here if our app is already running.

If you want to know, how py2app hands over file and folder paths to your application, right-click on your app in the dist folder and choose "Show Package Contents".
Navigate to Contents/Resources/ and open the file __boot__.py.

Actually you have multiple options to accept these AppleEvents while your app is running:

The app that I've been working on will need to run on other platforms than OS X, too, so I decided to stick with Tk. QT would have been another option, but I like the fact, that Tk bindings come with every Python installation. Choosing Tk and Python should provide greatest portability.

Adding Tk

Let's add some code to our odoc_demo.py file to display a simple GUI:

#!/usr/bin/env python # -*- coding: utf-8 -*- """This piece of code shows how to accept 'Open Document' Apple Events in your Python / Tk application.""" import os import sys import logging import logging.handlers from Tkinter import * # configure logging home_dir = os.path.expanduser('~') log_file = os.path.join(home_dir, "Library/Logs/com.moosystems.odoc_demo.log") log = logging.getLogger("main") 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) # callback which gets invoked when files or folders are sent to our app: def doOpenFile(*args): for f in args: if os.path.isdir(f): log.info("'%s' is a directory." % f) elif os.path.isfile(f): log.info("'%s' is a file." % f) else: log.info("'%s' doesn't exist." % f) # when the app starts up, check for command-line arguments: for f in sys.argv[1:]: doOpenFile(f) # create a simple window tk = Tk() # when app has started, check for double-clicked items: tk.createcommand("::tk::mac::OpenDocument", doOpenFile) fr=Frame(tk, height=200, width=200, bg="grey") fr.pack() # the following line brings your app to the front os.system('''/usr/bin/osascript -e 'tell app "Finder" to set frontmost of process "odoc_demo" to true' ''') tk.mainloop() log.info("Goodbye.")

Now we import Tk (use "from tkinter import *" if you are running Python 3) and open a simple grey window, when our app starts.

Start it on the command-line and double-click on an "odoc-demo" test file. You will see that the file path will be logged.

This happens because the line

tk.createcommand("::tk::mac::OpenDocument", doOpenFile)

creates an event handler, which catches the "odoc" command from the Finder.

Btw: the line

os.system('''/usr/bin/osascript -e 'tell app "Finder" to set frontmost of process "odoc_demo" to true' ''')

is necessary as Tk GUI applications by default don't become the frontmost app without this. I consider this line a workaround, which does its job.

You find a list of Tk's Mac specific functionality at http://tcl.tk/man/tcl/TkCmd/tk_mac.htm.

Build your app again, and you are done!

If want to test this on your system, you can download the code from odoc_demo.

Imprint
1