Part 2: Separating Concerns
Happy 2012, everyone! After a long hiatus, Stylish Programming in Python is back. In Part 0 and Part 1, we built a program to convert kilograms to pounds, and then cleaned it up so that the code and the interface clearly communicate its functionality to both users of the program and readers of the code. In this part, we will talk about how to make our code modular, so it can be reused in different environments.
Put it in a function
The code we created in Part 1 performed all of its input handling, calculation, and output rendering in one block of code. The first thing we want to do to improve that is move the calculation of the conversion into a function with a clear and descriptive name.
import sys def convert_pounds_to_kilograms(pounds): kilograms = pounds / 2.2 return kilograms if __name__ == '__main__': while 1: try: pounds = float(raw_input('Weight in lbs:')) except ValueError: sys.exit() kilograms = convert_pounds_to_kilograms(pounds) print "Weight in kg: %.1f" % kilograms
A function is a chunk of code that takes zero or more inputs, and returns a value based on those inputs. By putting our conversion into a function, we can use it in a number of different contexts. Note that we've gone to more approximate value for the conversion ratio, 2.2 lbs per kilogram. It's not precisely accurate, but it's close enough for the purposes of this blog post, and the most immediate benefit of putting the conversion into a function is that if we want to change it back to a more specific value in the future, we will only have to change it in one place. We are planning to use this function over and over, and we do not want to search through the code for every place we mention the number 2.2 when we do decide to change it.
Reusing the conversion function
If we need another script to create a file that contains a list of all pound to kilogram conversions in a given range, we can still use the same conversion function we created here. In a new file, chart_conversions.py, we put:
from converter import convert_pounds_to_kilograms def convert_range(filename, start, stop, step): with open(filename, 'w') as outfile: outfile.write('lb,kg') for pounds in xrange(start, stop, step): kilograms = convert_pounds_to_kilograms(pounds) outfile.write('%.1f,%.1f' % (pounds, kilograms)) if __name__ == '__main__': try: start = float(raw_input('Starting value:')) stop = float(raw_input('Ending value:')) step = float(raw_input('Interval between values: ') or 1) except ValueError: # In this case, a ValueError is a mistake, rather than a # legitimate exit, so we give the user a helpful error message # and then exit with a non-zero return code to indicate failure. print "ERROR: You must enter a numerical value." sys.exit(1) convert_range('conversions.csv', start, stop, step)
The user doesn't see any output at the terminal. It's now stored in a new file, called conversions.csv, which is appropriately formatted for importing into a LibreCalc or Excel spreadsheet.
$ python chart_conversions.py Starting value: 10 Ending value: 100 Interval between values:  10 $ ls chart_conversions.py conversions.csv converter.py
If we look in the new conversions.csv file, we'll see the following output:
lb,kg 10.0,4.5 20.0,9.1 30.0,13.6 40.0,18.2 50.0,22.7 60.0,27.3 70.0,31.8 80.0,36.4 90.0,40.9
We don't have to change our convert() function at all to do this. We've just wrapped it in a convert_range(). This is a big win, as we can do other things without having to recreate the conversion function all over the place. But we're still mixing concerns in convert_range(). It handles creating a range of inputs, running the conversion command on all of them, and writing the output to a file. We can do better. Let's say we don't always want to run conversions on values at regular intervals, but over a pre-determined set of weights, like all the dogs in a pet store. We want the same kind of output, but our inputs aren't flexible enough. If we change our abstractions a little bit, we can end up with something more flexible.
def convert_many_pounds_to_kilograms(pound_values): with open(filename, 'w') as outfile: outfile.write('lb,kg') for pounds in pound_values: kilograms = convert_pounds_to_kilograms(pounds) outfile.write('%.1f,%.1f' % (pounds, kilograms)) if __name__ == '__main__': try: start = float(raw_input('Starting value:')) stop = float(raw_input('Ending value:')) step = float(raw_input('Interval between values: ') or 1) except ValueError: # In this case, a ValueError is a mistake, rather than a # legitimate exit, so we give the user a helpful error message # and then exit with a non-zero return code to indicate failure. print "ERROR: You must enter a numerical value." sys.exit(1) pounds_values = xrange(start, stop, step) convert_many_pounds_to_kilograms('conversions.csv', pounds_values)
Our new convert_many_pounds_to_kilograms() function is much more flexible. Any standard python iterator, including lists, tuples, sets, xrange objects, generators and more can be passed to the function, and it will do the right thing with it.
Encapsulating input handling
We can also write a function to encapsulate the creation of the xrange object:
# [...] def raw_input_for_xrange(): try: start = float(raw_input('Starting value:')) stop = float(raw_input('Ending value:')) step = float(raw_input('Interval between values: ') or 1) except ValueError: # In this case, a ValueError is a mistake, rather than a # legitimate exit, so we give the user a helpful error message # and then exit with a non-zero return code to indicate failure. print "ERROR: You must enter a numerical value." sys.exit(1) return xrange(start, stop, step)
This violates the guideline we set earlier that a function should operate on its arguments to create a return value. It takes no arguments, and instead operates on values that it recieves from the user to create its return value. That's actually okay in this case. Input has to be translated into python objects somehow, and this sort of function is useful for isolating that behavior. The important thing is not to mix that with business logic. Note that this function doesn't care if the value s represent pounds, kilograms, monkeys, or raindrops. It takes numerical values, and creates an xrange() object from those values.
Encapsulating output rendering
Similarly, we can create a function that takes a list of number pairs, and creates output from those arguments, which it displays to the user. This function doesn't return a value (or more accurately, it returns the value None, which we immediately discard. The only purpose of this function is to handle the display of its input data.
def write_csv(rows, file_object=None): if not file_object: import sys file_object = sys.stdout for row in rows: file_object.write(','.join('%.1f' % element for element in row)) file_object.write('\n')
write_csv() takes a list of tuples of numerical data (or a comparable iterable of iterables) and writes a CSV to a file (or to STDOUT, if no file is specified). Much like our raw_input_for_xrange() function, it doesn't matter what the input means, as long as it can be formatted into a csv file. The only piece that cares that we are dealing with conversions is our actual conversion function, and the convert_many wrapper.
Notice that this function only works if each element in the row is numerical data. If we try to write other sorts of data to this function, it will fail when it tries to format the element at '%.1f' % element. If we want to make it more flexible, we could use '%s', but then we'd lose the ability to limit the output to one decimal point. There are a couple ways we could address this:
- We could format the data the way we want to before we pass it to write_csv().
- Since formatting is display-related logic, we could expand the write_csv() function to appropriately format different types of data.
Take some time to think about how you might implement each of these approaches, and what the benefits and drawbacks of each approach might be.
Putting it all together
We can now rewrite our convert_many_pounds_to_kilograms() function to return a list of conversions which write_csv() (or other functions we haven't written yet) can use to output a file.
# [...] def convert_many_pounds_to_kilograms(pound_values): conversions =  for pounds in pound_values: kilograms = convert_pounds_to_kilograms(pounds) conversions.append((pounds, kilograms)) return conversions if __name__ == '__main__': pound_values = raw_input_for_xrange() conversion_chart = convert_many_pounds_to_kilograms(pound_values) with open('conversion.csv', 'w') as outfile: outfile.write('lbs,kg\n') write_csv(conversion_chart, file_object=outfile)
Our input, output, and business logic are all fully separate in usable independently of one another. This is all well and good, but how is that useful to us? It means that each of those pieces can be reused. If we want to write another script that takes input and displays output in the same way, but instead of converting pounds to kilograms, it calculates the square roots of the inputs, we already have most of our program written. Alternatively, if we want to display our weight conversions differently, we don't have to rewrite the conversion function.
Building a converter web app
Let's say, for instance that in addition to writing a command line utility, we want to create a web service which renders pages that show pound-to-kilogram conversions. Let's start by creating a new script in the same directory that serves web pages.
The Python Standard Library provides a module with a basic web server, called, simply enough, BaseHTTPServer which will do what we need. As always, the Official Documentation is a great place to go for more information. Also, Doug Hellmann has put together a great resource called the Python Module of the Week, which provides fantastic tutorials on all the modules in the Python Standard Library that fill in the gaps of the official docs very nicely. The web server code in this tutorial is adapted from examples provided by Doug Hellmann.
# Web server adapted from Doug Hellmann's PyMOTW on BaseHTTPServer from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler class SimpleRequestHandler(BaseHTTPRequestHandler): def do_GET(self): lines = [ '<html>', '<head>', ' <title>Simple web page</title> '</head>', '<body>', ' <h1>Simple web page</h1>', ' <p>We\'ll add real content here later.</p>', '</body>', '</html>', ] self.send_response(200) self.end_headers() for line in lines: self.wfile.write(line) if __name__ == '__main__': server = HTTPServer(('localhost', 8000), SimpleRequestHandler) print 'Starting server. Use <Ctrl-C> to stop' server.serve_forever()
We can see our simple web page in our browser at http://localhost:8000/. The next step is to make our web server perform conversions for us. With the following code, if you go to http://localhost:8000/100, it will convert 100 kg to pounds.
# Web server from Doug Hellmann's PyMOTW on BaseHTTPServer from __future__ import division from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler import urlparse from converter import convert_pounds_to_kilograms class PoundsToKilogramsRequestHandler(BaseHTTPRequestHandler): def do_GET(self): parsed_path = urlparse.urlparse(self.path) path = parsed_path.path.rstrip('/') path = parsed_path.path.rstrip('/').split('/') pounds = float(path[-1]) kilograms = convert_pounds_to_kilograms(pounds) lines = [ '<html>', '<head>', ' <title>Convert %.1f pounds to kilograms</title>' % pounds, '</head>', '<body>', ' <h1>Convert %.1f pounds to kilograms</h1>' % pounds, ' <dl>', ' <dt>Pounds</dt>', ' <dd id="lb_value">%.1f</dd>' % pounds, ' <dt>Kilograms</dt>', ' <dd id="kg_value">%.1f</dd>' % kilograms, ' </dl>', '</body>', '</html>', ] self.send_response(200) self.end_headers() for line in lines: self.wfile.write(line) if __name__ == '__main__': server = HTTPServer(('localhost', 8000), PoundsToKilogramsRequestHandler) print 'Starting server. Use <Ctrl-C> to stop' server.serve_forever()
The new pieces added here are importing the converter, parsing the URL to get the value to be converted, and rendering and returning the web page.
We're starting to see some of the power of separating concerns, as our one humble convert_pounds_to_kilograms() function is now powering a number of different programs with unique interfaces. Each time, though, it is easy to fall into the trap of not separating concerns. In our web server, for instance, we have one function responsible for parsing the URL and rendering the web page. If we want to have a website that does more than one thing, our do_GET() method will get cumbersome.
Special challenge: Expand the web app
As a challenge for the reader, write a webserver that can take any of the following URLs and convert from pounds to kilograms.
Converts 150 pounds to kilograms.
Converts 15, 200, and 300 pounds to kilograms.
Converts 1, 2, 3, and 4 (not 5) pounds to kilograms.
Converts 0, 10, 20, 30, and 40 (not 50) pounds to kilograms.
Converts 57, 67, 77, 87, and 97 pounds to kilograms.
Note, and take advantage of, the similarities between this exercise and the CSV writer. Reuse code we've already written wherever possible. You will find this is easier if do_GET() is just a container, which calls separate methods for parsing the input, converting values, and rendering output. If you need to write multiple rendering methods, feel free.
As always, feel free to post questions, suggestions, corrections, or any other sort of feedback. I'm always looking for ways to improve this resource.
Next time, in Stylish Programming in Python 3: Handling Changing Needs, we will look at how to make our little converter more powerful. When you get a call from your client in Germany saying that they need to be able to convert from kilograms to pounds as well, and while you're at it, can you help them with liters to gallons as well?, you'll have to tools to accommodate their requests without having to start from scratch or triple your workload.