PyGest: A Python Tkinter Tutorial, Part 3

This is part three in our Python tkinter tutorial series on building a simple GUI app to compute and check file hash values. In part two, we added a banner header to our bare bones view by inserting a tkinter label into our app's content frame. In this segment, we will build out our app's various input widgets. Recall our mockup design:




We'll continue working our way from top to bottom, and add in those components relating to the application's inputs: 1) the file path, 2) an optional hash value to check against, and 3) a choice of hash functions to run against the file. Altogether, we'll have six objects to configure: two labels, two corresponding input fields, and two radio buttons. We'll also get a glimpse of tkinter's more advanced  functionality by implementing a native window dialogue that lets the user choose the file that will be hashed. This will be the most involved article in the series, so make sure your caught up with the current state of the code, get a cup of coffee and settle in.

Configuring the Inputs Frame
As with the banner, we'll create a new method to configure the input-related objects, and call it from our setup method. However, because there are a number of inputs, we'll group all the objects in this method inside their own frame, defined specifically for the purpose. To make space for this group's frame in the GUI, we'll also have to configure a new row in our main_frame object, and then drop the input objects frame into that row. To begin, let's get the preliminaries out of the way.


We've made a few additions to our View class in the snippet above. We've created a configure_inputs method with a simple log message, and called it from set_up(). In the configure_mainframe() method, we've added a single row to the app's outer frame. (We'll soon attach the inputs frame to this cell in the grid.) In configuring the new row, we identify it as row 1 and give it a weight of 1. Notice, the weights of both row 0 and row 1 are equivalent, which means they will expand and contract in the same proportion when the window is resized. We will change this in due time. Now on to the inputs.

In the configure_inputs method, we are going to configure a new frame using the grid geometry manager, and attach the various input objects to the appropriate cell in this grid. Looking at our mockup, the app's input objects are basically organized in a 3x2 grid: three columns to hold a label, an input field and a radio button in a single row, and two rows to hold each of the sets of these objects. We'll begin by creating our inputs frame and dropping it into the new row we just created in  the class's self.mainframe property. We will add columns and rows to our input frame as necessary.


Here we begin laying out our inputs frame with some basic settings. This frame is pretty much self-contained, so we don't have to make it accessible as a class attribute. We define it as a tkinter.Frame() object and pass in a number of arguments: self.mainframe is the pre-existing object we want to embed it in, and we also give it some styling to make it stand out against our temporary blue background. In the next line of code, we place the inputs frame in the desired grid cell in the self.mainframe object. Our banner is already at row 0, column 0. We'll put the inputs frame just below it, at row 1, column 0. We then make it sticky NSEW to start (we'll tweak this later as we add more elements to the frame). Finally, we configure column 0 and row 0 in the input_frame itself, giving each a weight of 1. By the end of this article, our inputs frame will have three columns and two rows.

Our input frame now has one cell, located at row 0, column 0. In our mockup, the top left object from among our input widgets is the label alerting the user to enter a file path. So let's add that first.


We define the file path label as a tkinter label object, and pass it the appropriate arguments to embed it in the inputs frame with a descriptive text. We also use kwargs to define a background and foreground color to get a sense for how the objects are laid out within the frame. In the next line of code, we then place the label at row 0, column 0, and make it stick to the east side of the cell because we predict it might be nice to have it flush against the entry input field once the other elements are added to the frame. Play around with some different variations of the sticky options to get a sense for how they affect the placement of the label text.

Just to the right of the file path label, we need an input field where the user can enter the path of the file to be hashed. In tkinter, this is called an Entry object. Let's think for a second about how we want to get this input. We can have the user actually type in or copy and paste the full file path as a text string. However, that is obviously not ideal. It would be more convenient for users to be presented with a dialog window that allows them to choose the file from their file system. Or better yet, both! As a first approach, we'll thus configure a text entry field so that when it is clicked it generates a file dialog that allows the user to choose the appropriate file from their local system. Then, when a file is chosen, its full path will be inserted into the entry field.

This will obviously make things a bit more complicated to code than simply just having the user enter a full path text string, but it will make the app much more user-friendly in the end. Plus, it will allow us to demonstrate one of the helpful additions to the tkinter library. Indeed, as we'll soon see, the tkinter library has just what we're looking for!

We thus want to configure a simple Entry object and then bind a click on that field to the generation of a file dialog. It makes sense to put the logic for the file dialog in a separate method. But we'll also have to access the Entry object from that method, which means we should define our Entry object as an attribute of the class. Here's our next snippet:


We want to embed this Entry object next to the file path label, so we have to add a new column to the input frame (see line 16). This is column 1 in the frame, and we'll give it a weight of 1 to start. Then, just below the label, we add the necessary code for the entry object. First, we define self.file_entry as a tkinter Entry object embedded in the inputs frame (line 22). In the next line of code, we call the bind method on the Entry object, and in the arguments, we connect a button-type single click event to our custom file dialog method, which we've called chooseFileName().  (For more info on events and bindings, see the effbot docs.) Finally, we call the grid method on the entry object to identify the cell we want to attach the entry object to in the inputs frame: it's going to be at row 0, column 1. As you can see from the above, the chooseFileName method is just a place holder at present, but it does log a message every time the method is called. If we run the code above, you should see something like this:


When you run the app, if you click inside the box delineating the Entry object, you should see the log message from the chooseFileName method, which is bound to be called on that event.

Weighting the Grid
However, if you run the app and play around with it a bit, you'll notice some strange behavior. For example, when you resize the window, you'll see that the banner header field expands with the window. Indeed, right now the banner will take up exactly half of the vertical space of the window, no matter what size it is. That's because there are only two rows in the main frame (one of which contains the banner header label object), and both of these rows have been configured to have a weight of 1, which means they resize proportionately to one another. Go back up to your configure_mainframe() method and change the configuration of row 0 to have a weight of 1: self.mainframe.rowconfigure(0, weight=0). You'll see the difference immediately: the banner header will no longer expand vertically when you resize the window.

Tkinter File Dialogs
Let's now fill out the chooseFileName() method. What exactly do we want this function to do? When the user clicks the file path entry field, we want a native-looking pop-up to appear that lets the user choose a file from his or her local system. Then, once that file is chosen, we want the window to close and the file path to appear in the file path text entry field. For this, we are going to utilize one of the new additions to Python tkinter: the filedialog submodule. It contains a number of helpful objects and functions, indeed, it has the exact one we are looking for: the askopenfilename() function.

As I understand it, the filedialog submodule has to be imported separately from tkinter itself. At the top of the script, let's thus import the filedialog submodule as fd: import tkinter.filedialog as fd. Our imports now look as follows:
import tkinter
import tkinter.filedialog as fd
import hashlib
import logging
Now, in our chooseFileName() method, we call the fd.askopenfilename() function, pass it a descriptive title, and log its return value (notice we're also passing the bound event itself to the method!):


If you run the app with this code, when you click on the file path entry field, it will generate a native file dialog asking you to choose a file from your local system. The title you supplied will be written to the top of this window. Furthermore, if you use the generated dialog window to choose a file, you'll see that the full path of the file is returned by the function when you pass the returned value to your logger.

Now we want to write that file path to our text entry field. This is accomplished by calling the insert method on the file entry class attribute object. We want to insert the file name beginning at index zero inside the entry field. This is why we needed to have access to the file_entry object outside of its method's local scope. So we add the following:



We're going to need to access the full file path later when we run the hash, so we might as well create a class instance attribute for the file path, and save the returned value from the file dialog to that variable. I also declare this attribute in the init() method just to be explicit about things. In chooseFileName, we call the insert method on the self.file_entry class attribute, and pass it two arguments: the index at which we want to insert the desired string, and the string returned by the file dialog. If you run the app, click the entry field and choose a file, the full path should now be written to the entry field once the file dialog window closes. Things are coming along nicely.

Radio Button and String Var
With our chooseFileName helper method finished, we can now return to work on configure_inputs(). Recall, our app is supposed to allow a choice between running a SHA1 hash or an MD5 hash of a file. SHA1 will be the default because it is more secure, so we'll place it on the top. Let's add the third object in the top row of the frame: a radio button that corresponds to the first option from among the two hash functions our app will utilize. First we have to add a third column to our inputs frame so we have somewhere to put the button! Of course, the third column is actually at column index 2 in our grid. The configuration of our inputs_frame object thus now looks as follows:


We're using radio buttons for the hash options rather than, say, check boxes, because a choice between radio button options is mutually exclusive. (It might be nice to perform multiple hashes at once, but we're trying to keep things simple here.) In our app, the user can choose to hash a file with SHA1 or MD5 by checking the appropriate radio button. We obviously need to keep track of which radio button the user has chosen so we can run the right hash function. To that end, we will utilize a tkinter string variable. We thus define a tkinter string variable, exposing it as a class attribute (since we will need to access the value of this variable to run the hash function), and give it a default value of 'sha1' with the set method, since that is the name of our default hash function. We add two lines to the bottom of our configure_inputs method to define the string variable:


We've also appended two more lines to define our top radio button. The first defines our sha1 variable as a tkinter.Radiobutton() object, and we pass in four arguments: 1) the name of the frame where we want to embed the button, 2) the text that will appear in the GUI next to the button, 3) the corresponding tkinter StringVar we want to associate this radio button with, and 4) the value to send to the string variable if this button is chosen. In the final line, we place the button in row 0, column 2 of the inputs frame identified in the previous line. Our app now looks something like this:


With that, we've reached the halfway point of our set task for the present article! At the top of our inputs frame, we've added 1) a label, 2) a text entry input, bound to a file dialog pop-up window event, and 3) a single radio button. We now have to add the second row. Like the previous row, it will hold: 1) a label indicating where the user can insert a hash value to compare against the hash of the file performed by the app, 2) an entry field where the user can insert that value, and 3) the MD5 radio button. Fortunately, we won't have to hook up the text entry input to a separate function, and now that we have the top row done, the second will be rather easy to implement.

First, we have to configure a second row in the input frame, and then we add two lines each for the label, text entry and radio button widgets:


We've added a digest_label to the inputs frame, with the text "Compare Digest:", and inserted it at row 1, column 0 in the frame, and made it sticky to the east. Secondly, since we are going to need to access the digest_entry field from other methods (in order to get the value we want to compare our hash with), we make it a class instance attribute and define it as an Entry object in the inputs frame. We then place it at row 1, column 1 in the grid and make it sticky to the east and west. Finally, we create our MD5 radio button, define it as belonging to the inputs frame, provide the text we want to appear in the app, define the variable it is attached to (i.e. the StringVar object variable we set up earlier, to which we've already attached our SHA1 button), and give it a value of 'md5' so we know when it has been selected. We then place it in row 1, column 2 of the grid. Our app is coming along nicely:


That took a bit of work, but now we're done configuring our app's inputs! Here is the current state of our code:


In the next article, we'll implement the various objects necessary to display our app's output. Thanks for following along! You can find part 4 in our Python tkinter tutorial series at the link.

2 comments: