- A Pop-Up Alert in 25 Lines
- An Expression Evaluator in 30 Lines
- A Currency Converter in 70 Lines
- Signals and Slots
- Summary
- Exercise
A Currency Converter in 70 Lines
One small utility that is often useful is a currency converter. But since exchange rates frequently change, we cannot simply create a static dictionary of conversion rates as we did for the units of length in the Length class we created in the previous chapter. Fortunately, the Bank of Canada provides exchange rates in a file that is accessible over the Internet, and which uses an easy-to-parse format. The rates are sometimes a few days old, but they are good enough for estimating the cash required for trips or how much a foreign contract is likely to pay. The application is shown in Figure 4.4.
Figure 4.4 The Currency application
The application must first download and parse the exchange rates. Then it must create a user interface which the user can manipulate to specify the currencies and the amount that they are interested in.
As usual, we will begin with the imports:
import sys import urllib2 from PyQt4.QtCore import * from PyQt4.QtGui import *
Both Python and PyQt provide classes for networking. In Chapter 18, we will use PyQt's classes, but here we will use Python's urllib2 module because it provides a very useful convenience function that makes it easy to grab a file over the Internet.
class Form(QDialog): def __init__(self, parent=None): super(Form, self).__init__(parent) date = self.getdata() rates = sorted(self.rates.keys()) dateLabel = QLabel(date) self.fromComboBox = QComboBox() self.fromComboBox.addItems(rates) self.fromSpinBox = QDoubleSpinBox() self.fromSpinBox.setRange(0.01, 10000000.00) self.fromSpinBox.setValue(1.00) self.toComboBox = QComboBox() self.toComboBox.addItems(rates) self.toLabel = QLabel("1.00")
After initializing our form using super(), we call our getdata() method. As we will soon see, this method gets the exchange rates, populates the self.rates dictionary, and returns a string holding the date the rates were in force. The dictionary's keys are currency names, and the values are the conversion factors.
We take a sorted copy of the dictionary's keys so that we can present the user with sorted lists of currencies in the comboboxes. The date and rates variables, and the dateLabel, are referred to only inside __init__(), so we do not keep references to them in the class instance. On the other hand, we do need to access the comboboxes and the toLabel (which displays the amount of the target currency), so we make these instance variables by using self.
We add the same sorted list of currencies to both comboboxes, and we create a QDoubleSpinBox, a spinbox that handles floating-point values. We provide a minimum and maximum value for the spinbox, and also an initial value. It is good practice to always set a spinbox's range before setting its value, since if we set the value first and this happens to be outside the default range, the value will be reduced or increased to fit the default range.
Since both comboboxes will initially show the same currency and the initial value to convert is 1.00, the result shown in the toLabel must also be 1.00, so we set this explicitly.
grid = QGridLayout() grid.addWidget(dateLabel, 0, 0) grid.addWidget(self.fromComboBox, 1, 0) grid.addWidget(self.fromSpinBox, 1, 1) grid.addWidget(self.toComboBox, 2, 0) grid.addWidget(self.toLabel, 2, 1) self.setLayout(grid)
A grid layout seems to be the simplest solution to laying out the widgets. When we add a widget to a grid we give the row and column position it should occupy, both of which are 0-based. The layout is shown schematically in Figure 4.5. Much more can be done with grid layouts. For example, we can have spanning rows and columns; all of this is covered later, in Chapter 9.
Figure 4.5 The Currency application's grid layout
If we look at the screenshot, or run the application, it is clear that column 0 of the grid layout is much wider than column 1. But there is nothing in the code that specifies this, so why does it happen? Layouts are smart enough to adapt to their environment, both to the space available and to the contents and size policies of the widgets they are managing. In this case, the comboboxes are stretched horizontally to be wide enough to show the widest currency text in full, and the spinbox is stretched horizontally to be wide enough to show its maximum value. Since comboboxes are the widest items in column 0, they effectively set that column's minimum width; and similarly for the spinbox in column 1. If we run the application and try to make the window narrower, nothing will happen because it is already at its minimum width. But we can make the window wider and both columns will stretch to occupy the extra space. It is, of course, possible to bias the layout so that it gives more horizontal space to, say, column 0, when extra space is available.
None of the widgets is initially stretched vertically because that is not necessary for any of them. But if we increase the window's height, all of the extra space will go to the dateLabel because that is the only widget on the form that likes to grow in every direction and has no other widgets to constrain it.
Now that we have created, populated, and laid out the widgets, it is time to set up the form's behavior.
self.connect(self.fromComboBox, SIGNAL("currentIndexChanged(int)"), self.updateUi) self.connect(self.toComboBox, SIGNAL("currentIndexChanged(int)"), self.updateUi) self.connect(self.fromSpinBox, SIGNAL("valueChanged(double)"), self.updateUi) self.setWindowTitle("Currency")
If the user changes the current item in one of the comboboxes, the relevant combobox will emit a currentIndexChanged() signal with the index position of the new current item. Similarly, if the user changes the value held by the spinbox, a valueChanged() signal will be emitted with the new value. We have connected all these signals to just one Python slot: updateUi(). This does not have to be the case, as we will see in the next section, but it happens to be a sensible choice for this application.
And at the end of __init__() we set the window's title.
def updateUi(self): to = unicode(self.toComboBox.currentText()) from_ = unicode(self.fromComboBox.currentText()) amount = (self.rates[from_] / self.rates[to]) * self.fromSpinBox.value() self.toLabel.setText("%0.2f" % amount)
This method is called in response to the currentIndexChanged() signal emitted by the comboboxes, and in response to the valueChanged() signal emitted by the spinbox. All the signals involved also pass a parameter. As we will see in the next section, we can ignore signal parameters, as we do here.
No matter which signal was involved, we go through the same process. We extract the "to" and "from" currencies, calculate the "to" amount, and set the toLabel's text accordingly. We have given the "from" text's variable the name from_ because from is a Python keyword and therefore not available to us. We had to escape a newline when calculating the amount to make the line narrow enough to fit on the page; and in any case, we prefer to limit line lengths to make it easier to read two files side by side on the screen.
def getdata(self): # Idea taken from the Python Cookbook self.rates = {} try: date = "Unknown" fh = urllib2.urlopen("http://www.bankofcanada.ca" "/en/markets/csv/exchange_eng.csv") for line in fh: if not line or line.startswith(("#", "Closing ")): continue fields = line.split(",") if line.startswith("Date "): date = fields[-1] else: try: value = float(fields[-1]) self.rates[unicode(fields[0])] = value except ValueError: pass return "Exchange Rates Date: " + date except Exception, e: return "Failed to download:\n%s" % e
This method is where we get the data that drives the application. We begin by creating a new instance attribute, self.rates. Unlike C++, Java, and similar languages, Python allows us to create instance attributes as and when we need them—for example, in the constructor, in the initializer, or in any other method. We can even add attributes to specific instances on the fly.
Since a lot can go wrong with network connections—for example, the network might be down, the host might be down, the URL may have changed, and so on, we need to make the application more robust than in the previous two examples. Another possible problem is that we may get an invalid floating-point value such as the "NA" (Not Available) that the currency data sometimes contains. We have an inner try ... except block that catches invalid numbers. So if we fail to convert a currency value to a floating-point number, we simply skip that particular currency and continue.
We handle every other possibility by wrapping almost the entire method in an outer try ...except block. (This is too general for most applications, but it seems acceptable for a tiny 70-line application.) If a problem occurs, we catch the exception raised and return it as a string to the caller, __init__(). The string that is returned by getdata() is shown in the dateLabel, so normally this label will show the date applicable to the exchange rates, but in an error situation it will show the error message instead.
Notice that we have split the URL string into two strings over two lines because it is so long—and we did not need to escape the newline. This works because the strings are within parentheses. If that wasn't the case, we would either have to escape the newline or concatenate them using + (and still escape the newline).
We initialize the date variable with a string indicating that we don't know what dates the rates were calculated. We then use the urllib2.urlopen() function to give us a file handle to the file we are interested in. The file handle can be used to read the entire file in one go using its read() method, but in this case we prefer to read line by line using readlines().
Here is the data from the exchange_eng.csv file on one particular day. Some columns, and most rows, have been omitted; these are indicated by ellipses.
... # Date (<m>/<d>/<year>),01/05/2007,...,01/12/2007,01/15/2007 Closing Can/US Exchange Rate,1.1725,...,1.1688,1.1667 U.S. Dollar (Noon),1.1755,...,1.1702,1.1681 Argentina Peso (Floating Rate),0.3797,...,0.3773,0.3767 Australian Dollar,0.9164,...,0.9157,0.9153 ... Vietnamese Dong,0.000073,...,0.000073,0.000073
The exchange_eng.csv file's format uses several different kinds of lines. Comment lines begin with "#", and there may also be blank lines; we ignore all these. The exchange rates are listed by name, followed by rates, all comma-separated. The rates are those applying on particular dates, with the last one on each line being the most recent. We split each of these lines on commas and take the first item to be the currency name, and the last item to be the exchange rate. There is also a line that begins with "Date"; this lists the dates applying to each column. When we encounter this line we take the last date, since that is the one that corresponds with the exchange rates we are using. There is also a line that begins "Closing"; we ignore it.
For each exchange rate line we insert an item into the self.rates dictionary, using the currency's name for the key and the exchange rate as the value. We have assumed that the file's text is either 7-bit ASCII or Unicode; if it isn't one of these we may get an encoding error. If we knew the encoding, we could specify it as a second argument when we call unicode().
app = QApplication(sys.argv) form = Form() form.show() app.exec_()
We have used exactly the same code as the previous example to create the QApplication object, instantiate the Currency application's form, and start off the event loop.
As for program termination, just like the previous example, because we have subclassed QDialog, if the user clicks the close X button or presses Esc, the window will close and then PyQt will terminate the application. In Chapter 6, we will see how to provide more explicit means of termination, and how to ensure that the user has the opportunity to save any unsaved changes and program settings.
By now it should be clear that using PyQt for GUI programming is straightforward. Although we will see more complex layouts later on, they are not intrinsically difficult, and because the layout managers are smart, in most cases they "just work". Naturally, there is a lot more to be covered—for example, creating main window-style applications, creating dialogs that the user can pop-up for interaction, and so on. But we will begin with something fundamental to PyQt, that so far we have glossed over: the signals and slots communication mechanism, which is the subject of the next section.