Extending our PyGTK Application

Article by Mark Mruss, originally posted on www.learningpython.com

In this tutorial we are going to extend our PyWine application to allowing you to edit the items that you have added to the list and save an load the wine lists you create so that you don't have to keep entering them in all the time.

You can download the complete code for this tutorial here.

If you are not familiar with the PyWine application or with working with Glade and PyGTK I suggest you read my first two tutorial on the subject:

The GUI - Glade

The first thing that we are going to do is open up out PyWine glad project and add an edit button to our toolbar:

  1. Make space for the Edit button by selecting the toolbar and settings its size property to 2.
  2. Add a Toolbar Button in the newly created empty space.
  3. Call the button btnEditWine, set its label to be "Edit Wine", and its icon to be the stock Edit icon. Next add a handler for the clicked event.
  4. We're going to change the menu up a little bit, instead of a menu that says Add | Wine, we are going to set the menu up to read Wine | Add and Wine | Edit. Do this just like we did in the previous PyWine tutorial and make the Wine | Edit clicked handler the same function as your btnEditWine button's clicked event handler.

GLADE Window PyWine

The Code

Now lets get the Edit button working in the code, the first thing that we are going to have to do is get the information from whatever line in the gtk.TreeView is currently selected. There are two ways to go about his, the first way is to read all the of data from the four columns that we have visible and the second would be to actually add our Wine object to the gtk.ListStore (our model) but not display it in the gtk.TreeView.

Since it is simpler and may be more useful in the future if our wine class contains extra information or if we choose to let people add or remove columns from the gtk.TreeView I choose to use the later approach. This means that we need to change some of our code a little but.

First we have to add an additional column to our column definition variables in the pyWine __init__ function:

self.cWineObject = 0
self.cWine = 1
self.cWinery = 2
self.cGrape = 3
self.cYear = 4

You see that we put the actual wine object as position 0 in the list, so we have to adjust our gtk.ListStore creation code in the same function as follows:

#Create the listStore Model to use with the wineView
self.wineList = gtk.ListStore(gobject.TYPE_PYOBJECT
							, gobject.TYPE_STRING
							, gobject.TYPE_STRING
							, gobject.TYPE_STRING
							, gobject.TYPE_STRING)

Everything is the same as it was before except that the first item in our gtk.ListStore will now be a python object. In order to get the above code to compile we must add the following code to the top of our file:

import gobject

Now the next thing that we need to change the way that we add our wine to the gtk.ListStore so that we actually include the Wine object. Luckily in our previous tutorial we added a getList() function to our Wine class that returns the list to add to the gtk.ListStore(), so all we have to do is edit that:

def getList(self):
	"""This function returns a list made up of the
	wine information.  It is used to add a wine to the
	wineList easily"""
	return [self, self.wine, self.winery, self.grape, self.year]

It's not much of a change, we simple have to make it so that getList() puts the Wine class at the start of the list.

The next step is to actually allow the user to edit a wine entry but before we do that there is one more change that we need to make. In tutorial one the __init__ function of the wine dialog accepted all of the items that made up our wine class as initialization parameters.

def __init__(self, wine="", winery="", grape="", year=""):

This works alright if you have a small number or parameters, but if our Wine class was going to grow initializing the wineDialog class would become a pain. So all we are going to do is change the __init__ function to accept a Wine class object, rather then all of its parts:

def __init__(self, wine=None):
	"""Initialize the class.
	wine - a Wine object"""

	#setup the glade file
	self.gladefile = "pywine.glade"
	#setup the wine that we will return
	if (wine):
		#They have passed a wine object
		self.wine = wine
	else:
		#Just use a blank wine
		self.wine = Wine()

The next step is to finally edit a wine entry, we will do this in function called on_EditWine(), it is hooked up to the Edit Wine button clicked event and the Wine | Edit menu item:

def on_EditWine(self, widget):
	"""Called when the user wants to edit a wine entry"""

	# Get the selection in the gtk.TreeView
	selection = self.wineView.get_selection()
	# Get the selection iter
	model, selection_iter = selection.get_selected()

	if (selection_iter):
		"""There is a selection, so now get the the value at column
		self.cWineObject, the Wine Object"""
		wine = self.wineList.get_value(selection_iter, self.cWineObject)
		# Create the wine dialog, based off of the current selection
		wineDlg = wineDialog(wine);
		result,newWine = wineDlg.run()

		if (result == gtk.RESPONSE_OK):
			"""The user clicked Ok, so let's save the changes back
			into the gtk.ListStore"""
			self.wineList.set(selection_iter
					, self.cWineObject, newWine
					, self.cWine, newWine.wine
					, self.cWinery, newWine.winery
					, self.cGrape, newWine.grape
					, self.cYear, newWine.year)

The first thing we do is call gtk.TreeView.get_selection() to get the gtk.TreeSelection object that is associated with the gtk.TreeView. Then we call gtk.TreeSelection.get_selected() which returns our gtk.TreeModel (which we don't care about) and a gtk.TreeIter that points to the currently selected node in out gtk.TreeView (which we do care about).

The gtk.TreeIter returned by the get_selected() function will be None if there is no selection, otherwise we use that gtk.TreeIter to get the Wine object at the currently selected position in our gtk.TreeView by calling the gtk.TreeModel.get_value() action. Once we have the Wine object the rest is pretty straight forward, we create our wineDialog object, show it, and if the used clicks the Ok button we update the selected item in the gtk.TreeView using the gtk.ListStore.set() function.

The gtk.ListStore.set() function is actually quite interesting since it takes a gtk.TreeIter as its first parameter (the position to set the values) and the rest of its parameters can be one or more column_number, new_value pairs! My only disappointment was not finding a function that used a list in the same way that the gtk.ListStore.append() function does.

So that's it for editing a wine entry! Since we don't want to always re-enter the wines that we like each time we start the application it's high time that we start saving and loading our wine list.

Saving and Loading the Wine Lists

The first thing that we are going to do is borrow two helper functions from the WordPy offline blogging tool tutorial:

def show_error_dlg(self, error_string):
	"""This Function is used to show an error dialog when
	an error occurs.
	error_string - The error string that will be displayed
	on the dialog.
	"""
	error_dlg = gtk.MessageDialog(type=gtk.MESSAGE_ERROR
				, message_format=error_string
				, buttons=gtk.BUTTONS_OK)
	error_dlg.run()
	error_dlg.destroy()

This function just provides us with an easy way to let the user know that an error has occurred by showing them an error dialog. We will add this to the pyWine class. For more information on how the function works please see the WordPy offline blogging tool tutorial.

We are also going to bring over the browse_for_image() function:

def browse_for_image(self):
	"""This function is used to browse for an image.
	The path to the image will be returned if the user
	selects one, however a blank string will be returned
	if they cancel or do not select one."""

	file_open = gtk.FileChooserDialog(title="Select Image"
				, action=gtk.FILE_CHOOSER_ACTION_OPEN
				, buttons=(gtk.STOCK_CANCEL
							, gtk.RESPONSE_CANCEL
							, gtk.STOCK_OPEN
							, gtk.RESPONSE_OK))
	"""Create and add the Images filter"""
	filter = gtk.FileFilter()
	filter.set_name("Images")
	filter.add_mime_type("image/png")
	filter.add_mime_type("image/jpeg")
	filter.add_mime_type("image/gif")
	filter.add_pattern("*.png")
	filter.add_pattern("*.jpg")
	filter.add_pattern("*.gif")
	file_open.add_filter(filter)
	"""Create and add the 'all files' filter"""
	filter = gtk.FileFilter()
	filter.set_name("All files")
	filter.add_pattern("*")
	file_open.add_filter(filter)

	"""Init the return value"""
	result = ""
	if file_open.run() == gtk.RESPONSE_OK:
		result = file_open.get_filename()
	file_open.destroy()

	return result

Except we are going to modify it so that it operates as a File Open and File Save dialog, and so that it browses for pyWine files (*.pwi) instead of images. We will control whether it is a File Open or File Save dialog by passing in an additional parameter called dialog_action, which will be the action that we use to set the action property of the gtk.FileChooserDialog:

def file_browse(self, dialog_action, file_name=""):
	"""This function is used to browse for a pyWine file.
	It can be either a save or open dialog depending on
	what dialog_action is.
	The path to the file will be returned if the user
	selects one, however a blank string will be returned
	if they cancel or do not select one.
	dialog_action - The open or save mode for the dialog either
	gtk.FILE_CHOOSER_ACTION_OPEN, gtk.FILE_CHOOSER_ACTION_SAVE
        file_name - Default name when doing a save"""

	if (dialog_action==gtk.FILE_CHOOSER_ACTION_OPEN):
		dialog_buttons = (gtk.STOCK_CANCEL
							, gtk.RESPONSE_CANCEL
							, gtk.STOCK_OPEN
							, gtk.RESPONSE_OK)
	else:
		dialog_buttons = (gtk.STOCK_CANCEL
							, gtk.RESPONSE_CANCEL
							, gtk.STOCK_SAVE
							, gtk.RESPONSE_OK)

	file_dialog = gtk.FileChooserDialog(title="Select Project"
				, action=dialog_action
				, buttons=dialog_buttons)
	"""set the filename if we are saving"""
	if (dialog_action==gtk.FILE_CHOOSER_ACTION_SAVE):
		file_dialog.set_current_name(file_name)
	"""Create and add the pywine filter"""
	filter = gtk.FileFilter()
	filter.set_name("pyWine database")
	filter.add_pattern("*." + FILE_EXT)
	file_dialog.add_filter(filter)
	"""Create and add the 'all files' filter"""
	filter = gtk.FileFilter()
	filter.set_name("All files")
	filter.add_pattern("*")
	file_dialog.add_filter(filter)

	"""Init the return value"""
	result = ""
	if file_dialog.run() == gtk.RESPONSE_OK:
		result = file_dialog.get_filename()
	file_dialog.destroy()

	return result

FILE_EXT is simply defined as follows:

FILE_EXT = "pwi"

We are also going to want to add handlers for the File | Open and File | Save menu commands in our glad project using the same method that we did for the Wine | Add and Wine | Edit menu items. I called mine on_file_open and on_file_save:

#Create our dictionay and connect it
dic = {"on_mainWindow_destroy" : self.on_Quit
		, "on_AddWine" : self.OnAddWine
		, "on_EditWine" : self.on_EditWine
		, "on_file_open" : self.on_file_open
		, "on_file_save" : self.on_file_save}
self.wTree.signal_autoconnect(dic)

To do the saving and loading of our Wine objects we are going to use the python shelve module. Which is a standard python module that can most (if not all) python object. There are of course other ways that we could have done this, we could have used xml files, or a straight pickle of all of our objects, but I thought that shelve made sense in this situation and it is easier to introduce them xml saving and loading.

From the documentation:

A "shelf'' is a persistent, dictionary-like object. The difference with "dbm'' databases is that the values (not the keys!) in a shelf can be essentially arbitrary Python objects - anything that the pickle module can handle. This includes most class instances, recursive data types, and objects containing lots of shared sub-objects. The keys are ordinary strings.

Saving

So to start lets setup the on_file_save() function. To start we will let the user browse for a location where they want to save their file, and let them specify the file's name. Next we will ensure that that the file has our file extension on it and then we will loop through all the items in the gtk.TreeView and save each Wine object using the shelve module:

def on_file_save(self, widget):
	"""Called when the user wants to save a wine list"""

	# Get the File Save path
	save_file = self.file_browse(gtk.FILE_CHOOSER_ACTION_SAVE, self.project_file)
	if (save_file != ""):
		# We have a path, ensure the proper extension
		save_file, extension = os.path.splitext(save_file)
		save_file = save_file + "." + FILE_EXT
		""" Now we have the "real" file save loction create
		the shelve file, use "n" to create a new file"""
		db = shelve.open(save_file,"n")
		"""Get the first item in the gtk.ListStore, and while it is not
		None, move forward through the list saving each item"""
		# Get the first item in the list
		iter = self.wineList.get_iter_root()
		while (iter):
			# Get the wine at the current gtk.TreeIter
			wine = self.wineList.get_value(iter, self.cWineObject)
			# Use the iters position in the list as the key name
			db[self.wineList.get_string_from_iter(iter)] = wine
			# Get the next iter
			iter = self.wineList.iter_next(iter)
		#close the database and write changes to disk, we are done
		db.close();
		#set the project file
		root, self.project_file = os.path.split(save_file)

After working with the gtk.TreeIter objects earlier this code should not be that difficult to understand. In fact the only real difficult part of the code is the following, the rest should be explained by the in-line comments:

while (iter):
	# Get the wine at the current gtk.TreeIter
	wine = self.wineList.get_value(iter, self.cWineObject)
	# Use the iters position in the list as the key name
	db[self.wineList.get_string_from_iter(iter)] = wine
	# Get the next iter
	iter = self.wineList.iter_next(iter)

Basically what we are doing is looping through each item in the gtk.ListStore and then setting the data at the current gtk.TreeIter position in the shelve file to be our wine object.

db[self.wineList.get_string_from_iter(iter)] = wine

The gtk.TreeModel.get_string_from_iter() function "returns a string representation of the path pointed to by iter. This string is a ‘:' separated list of numbers. For example, "4:10:0:3″ would be an acceptable return value for this string." (pyGTK Docs). Since we are using a gtk.ListStore the values returned will always be single values that increase as we move down the list from top to bottom.

So the first item will be "0″, the second "1″, the third "2″, and so on. This will be helpful for us when we open files, since keys in shelve files are not guaranteed to be in any particular order (as far as I could tell.)

When you close the shelve file, the data will be written to the disk.

You'll also notice the inclusion of the self.project_file item as the default file name, this is a new addition to the class. It is the file name of the current project, it just lets us set the default name in the gtk.FileChooserDialog when we are doing a save. It's defined in the __init__ function as follows:


self.project_file = ""

This just lets us have a dialog that pops up like so:

GLADE Window PyWine

Loading

Now lets setup the on_file_open() function which, if you've understood the on_file_save function, should not be that difficult to understand:

def on_file_open(self, widget):
	"""Called when the user wants to open a wine"""

	# Get the file to open
	open_file = self.file_browse(gtk.FILE_CHOOSER_ACTION_OPEN)
	if (open_file != ""):
		# We have a path, open it for reading
		try:
			db = shelve.open(open_file,"r")
			if (db):
				# We have opened the file, so empty out our gtk.TreeView
				self.wineList.clear()
				""" Since the shelve file is not gaurenteed to be in order we
				move through the file starting at iter 0 and moving our
				way up"""
				count = 0;
				while db.has_key(str(count)):
					newwine = db[str(count)]
					self.wineList.append(newwine.getList())
					count = count +1
				db.close();
				#set the project file
				root, self.project_file = os.path.split(open_file)
			else:
				self.show_error_dlg("Error opening file")
		except:
			self.show_error_dlg("Error opening file")

You'll notice that when loading items from the list we use a counter (count) and the has_key() function. As explained above we save each Wine object using the gtk.TreeIter path, which is a single number since we are using a gtk.ListStore. But since the order in the file cannot be guaranteed we use our own counter to get each item from the file in order starting at zero and working our way up until the key represented by our number is no longer in the file. (Note: we convert out integer to a string since the keys in the shelve files must be strings.)

To load a Wine object from the file we simply ask the shelve file for the item at the current count key:

newwine = db[str(count)]

Then we just append that wine to the list, and we have loaded a .pwi file. The Try except code, basically just catches any error that might occur if the user tries to open a file that is not a true pyWine project file.

Conclusion

That's basically it for this tutorial, but if you've understood it you can see how easy to would be to hook in the File | New menu handler or add a Delete button to the toolbar, or even set the title of the Window to be the current project file.

You might even want to try playing around with different project file types and try implementing an XML file type. In the future I think it would be a neat option to allow the user to decide which type of file they want to use for their project files.

You can download the complete code for this tutorial here.

If you have any questions, or notice any problems with this tutorial please post a comment and let me know!