To allow more time to help my wife at her restaurant, I’ve made a career change to a position with more flexible hours. Now I’m working as Technical Writer in a large electronic design software company, as part of a team that creates leading-edge documentation for the company’s flagship product. Being a huge consumer of technical writing, I know how difficult it is to create compelling, concise, accurate and readable documentation. On a daily basis, I’m googling for information on some programming issue, or referring to safaribooksonline.com in search of a more elegant solution to solve a problem I’ve encountered. On the other hand, the more technical writing I consume, the more I question how the content is generated.
Until the recession, the field was filled with Technical Writers who were expert in crafting grammatically correct sentences, weaving them together into coherent paragraphs, organizing paragraphs into sections, gathering the sections to create chapters, and folding the chapters into a book. Yet, I wonder if most technical writers have ever done a sustained deep dive into a technical manual in order to master a technical subject or software tool. Yes, technical writing should be grammatically correct and use good sentence structure. But in my experience, the writing itself is much less important than the accuracy of the material, the completeness of the content, the organization of the material, and the quality of the examples. The writers of Developing Quality Technical Information: A Handbook for Writers and Editors certainly understood this when they advocated a minimalist style. Readers don’t want to wade through pages of text, they just want to get to the command, options, menu pick, process or technique that will get the job done for them.
Like lines of code in program, less is always better if it can serve the same function. Leave the superfluous adverbs and adjectives to the Marketing department, and let’s focus on the core information our users need. Strive to make the writing accurate and precise, eliminate the unnecessary cruft.
I have an audacious goal for you writers out there: make the accuracy of your content a key concern. Despite twenty five years of inertia, it’s happening where I work, and I want to share the secrets with you. In the coming posts, I’ll share with you some simple tools and techniques to raise the quality of your documentation. By sharing this information with others in your organization, you can help them at the same time.
Thank goodness for Python. My wife asked me this morning, “Honey, can you give me the history of raises for Steve Smithfield and Jeff Johnson”. I told her I’ll look into it, and thought how I might use Python to tackle the problem.
I have the entire history of payroll captured across ninety six spreadsheets, one for each pay period.
To manually click through each spreadsheet, locate the pay rate for the Steve and Jeff, write it down, and go to the next spreadsheet would take about 30 seconds per spreadsheet, or almost 50 minutes. I decided to invest 10 minutes in a Python script I could use over and over. The script basically opens every .xls file in the local directory and creates a list of employee names in the spreadsheet. If Steve or Jeff are found in the list, their salary is appended to a list. After all the spreadsheets are read, the script prints out the results. The spreadsheets are named “Timesheet_20120101.xls” for January 1, 2012, “Timesheet_20120515.xls” for May 15, 2012, etc.
Let’s be honest for a second, when was the last time you saw a Windows user running something from the command prompt? Well, I do it occasionally, but I can’t say I remember seeing a non-IT person using the command prompt recently. So if you’re going to offer your users a Windows program, you better give them an icon to click and let them drag stuff onto it. And if something goes wrong, you better have a decent error message. This post will take the Pivot Table generation script developed in the Extending Pivot Table Data post and turn it into a user friendly Windows program with better flexibility and improved user experience.
The scripts developed previously could be run at the command line or by double clicking on the icon for the script line this.
This works because the input file name, ABCDCatering.xls, is hard coded within the script. In the real world, your users have folders containing dozens of randomly named spreadsheets. If a user accidentally provides a corrupt spreadsheet, the program should keep cranking through the other files and let the user recover the damaged file later. The script developed in the last post needs some enhancements to make it more user friendly, including:
Provide support for multiple randomly named input spreadsheets
Add some simple message boxes and drag-and-drop support
Improve the error checking and error recovery to give the user feedback when something goes wrong
To keep things concise, this version of the script only allows the user to run the program by dragging and dropping files onto the program icon. Enhancing the script to also support command line operation is left as an exercise for the user. Let’s work through each of the usability issues below:
Multiple File Support
As I mentioned, Windows XP/Vista/7 users typically don’t interact with the command prompt. Instead, programs are run by clicking on their icons, either from the desktop, a folder, or the Start menu. A user specifies spreadsheets or document files by opening them in the application or dragging them onto the program icon on the desktop or in the Explorer window. You can also add the file names after the program name at the command prompt if needed.
To process multiple files, the program needs to process command line args, which are already conveniently available in the sys.argv list. Note that the first argument sys.argv[0] is used for the script name. The runexcel function is modified to pass sys.argv to the runexcel function, which loops through each of the input files.
if __name__ == "__main__":
runexcel(sys.argv)
for fname in args[1:]:
# Process spreadsheet files
The for loop wraps the wb = excel.Workbooks.Open(fname) call, the wb.SaveAs() call, and everything in between so each workbook is processed within the loop. After the loop finishes, a check for errors is made. If any errors occurred a warning and message box are issued.
Primitive GUI Support
Adding message boxes and providing basic drag-and-drop support adds a level of familiarity for Windows users. Python supports a large number of GUI frameworks, seehttp://wiki.python.org/moin/GuiProgramming for a comprehensive list. Building a complete graphic interface for this script is beyond the scope of this article, and isn’t really necessary anyway. Instead, you can add support for simple message boxes using the MessageBoxA function built into Windows. The basic pattern for calling a message box using this technique is to import ctypes and call windll.user32.MessageBoxA:
from ctypes import *
windll.user32.MessageBoxA(None,"My Message Box","Program Name",0)
This simple code produces a message box with the text “My Message Box”, an OK button, and “Program Name” as the top banner. When Python encounters windll.user32.MessageBoxA(), program execution pauses until the user clicks the OK button.
Improve Error Checking
Lots of problems can happen when reading user spreadsheet data. The user can forget to specify an input file. They could try to have the script read a Word document or other non-spreadsheet file type. The spreadsheet might be corrupted. You need to bulletproof your script and guard against potential issues, both known and unknown.
Previous versions of the script made limited use of the try/except pattern to catch errors.
try:
wb = excel.Workbooks.Open('ABCDCatering.xls')
except:
print "Failed to open spreadsheet ABCDCatering.xls"
sys.exit(1)
erppivotdragdrop.py makes more liberal use of try/except, wrapping more of the program code in the try block. If an error occurs, it can be handled more cleanly with nice warning messages. The downside of using try/except is that you lose the traceback message telling you where the error occurred. To get this information back, use the traceback module and the traceback.print_exc()function. One usage is to call traceback.print_exc() in the except block like this:
import traceback
try:
a = 1/0
except:
# Do error recovery
traceback.print_exc()
Now exceptions are caught, handled, and a more detailed traceback is still available.
Running the script
Let’s test out the script. First, copy the script to the desktop and drag the ABCDCatering.xls spreadsheet onto the icon. Python starts running in the command window and begins processing the file you dragged. If everything ran successfully, you’ll see a series of messages and the “Finished” message box.
If a problem occurred, a message is displayed in the command window. At the end of the run, the message box is displayed letting you know that something bad happened and that you should review the error messages.
The completed script is too long to reproduce here, please go here to view the complete script.
As shown in the last post, Automating Pivot Tables with Python, Python and Excel can help you quickly clean up a spreadsheet, organize data and build useful reports in very few lines of code. Another useful data preparation technique is to build new columns of information based on the available data. For example, you could add an industry segment column to group company names by industry, or add an item type column to group sales items by category. While Excel does have some functions to help with adding new data fields, automation with Python eliminates the tedium of clicking column names and entering formulas.
Excel does provide a function for calculating new values within a pivot table. One example is extending a pivot table containing pricing and quantity data to compute an average selling price. For example, given the table below:
a new label called “ASP”, which is the Net Booking divided by the Quantity, can be added quickly and easily with Excel’s Calculated Field capability.
This feature is handy for adding labels on the fly that require a simple calculation.
In other cases, deriving the new field may not be so simple, yet needs to be performed each time the spreadsheet is updated. Python can programmatically add new data fields to the source table so that the data is ready for viewing whenever the pivot table is opened.
The script developed last time automated the data cleanup and pivot table generation tasks. Doing some further analysis based on the output spreadsheet, I created a chart of the Top 10 Customers for ABCD Catering:
Note that some of the company names are 15 characters or longer in length and occupy much of the chart space. It would be nice to have a shorter “nickname” for each company that could be used in the charts. One solution is to cut and paste the pivot table data, then modify the Company Name information by hand. Unfortunately, this would be very tedious. Another approach is to automate the process in the script and create a new column derived from a comprehensive reference table of company names and nicknames. The downside is that maintaining the list could be an issue as the business grows and the list of customers grows longer. A third method is to create an algorithm that uses the first word in the company name wherever possible, and uses a defined nickname for other special cases. “Sun Microsystems” becomes “Sun” and “Cisco Systems” becomes “Cisco”, while other company names such as “Hewlett-Packard” could be listed in a lookup with a nickname such as “HP”. The snippet below shows how this is done.
logolookup = {'Applied Materials':'AMAT', 'Electronic Arts':'EA',
'Hewlett-Packard':'HP', 'KLA-Tencor':'KLA'}
if ("Company Name" in newdata[0]):
cindx = newdata[0].index("Company Name")
newdata[0][cindx+1:cindx+1] = ["Logo Name"]
for rcnt in range(1,len(newdata)):
if newdata[rcnt][cindx] in logolookup:
newdata[rcnt][cindx+1:cindx+1] = [logolookup[newdata[rcnt][cindx]]]
else:
newname = newdata[rcnt][cindx].split()[0]
newdata[rcnt][cindx+1:cindx+1] = [newname]
logolookup[newdata[rcnt][cindx]] = newname
This code begins with a simple lookup for company names and can be easily extended as special case company names are added. Next, the column location of the “Company Name” field is identified and the new header “Logo Name” is inserted after “Company Name” in the list using the :file:` list[index:index]` construct. The :file:` for` loop iterates over each row in the table, checking whether the company name for that row exists in the :file:` logolookup` dictionary, then inserting the abbreviated name. If not found, then the original company name is :file:` split()` into words and the first word used as the new abbreviated name. Finally, the :file:` logolookup` dictionary is updated with the new abbreviated name.
After running the program, the new column “Logo Name” has been inserted after “Company Name” and contains the shortened company names.
The new “Logo Name” column can be used in the previous pivot table and chart, replacing the “Company Name” field and producing a cleaner chart with less area used for displaying company name information.
Another use of this technique is to add a label for “Food Category” based on the type of food purchased. For example, the food items sold by ABCD Catering are: Caesar Salad, Cheese Pizza, Cheeseburger, Chocolate Sundae, Churro, Hamburger, Hot Dog, Pepperoni Pizza, Potato Chips and Soda. Let’s say that your manager wants to track the sales of different food categories, such as Burger, Dessert, HotDog, Drink, Pizza, Salad and Snack. Using the same technique outlined above, this code will add a column for Food Category with the appropriate entry for each food item:
foodlookup = {'Caesar Salad':'Salad', 'Cheese Pizza':'Pizza',
'Cheeseburger':'Burger', 'Chocolate Sundae':'Dessert',
'Churro':'Snack', 'Hamburger':'Burger', 'Hot Dog':'HotDog',
'Pepperoni Pizza':'Pizza', 'Potato Chips':'Snack',
'Soda':'Drink'}
if ("Food Name" in newdata[0]):
cindx = newdata[0].index("Food Name")
newdata[0][cindx+1:cindx+1] = ["Food Category"]
for rcnt in range(1,len(newdata)):
if newdata[rcnt][cindx] in foodlookup:
newdata[rcnt][cindx+1:cindx+1] = [foodlookup[newdata[rcnt][cindx]]]
else:
newdata[rcnt][cindx+1:cindx+1] = ['UNDEFINED']
If a food item is not found in the lookup, the category is labeled UNDEFINED. This is an indication that there is a problem with the script and the lookup for food categories needs to be extended.
The section of the script which creates the pivot tables can be easily extended to build a new table based on the newly created label “Food Category”:
# What food category had the highest unit sales in Q4?
ptname = addpivot(wb,src,
title="Unit Sales by Food Category",
filters=("Fiscal Quarter",),
columns=(),
rows=("Food Category",),
sumvalue="Sum of Quantity",
sortfield=("Food Category",win32c.xlDescending))
wb.Sheets("Unit Sales by Food Category").PivotTables(ptname).PivotFields("Fiscal Quarter").CurrentPage = "2009-Q4"
Based on the output spreadsheet, the best selling food category in Q4 based on quantity is “Snack”, with sales of 13700 units.
Here is the completed erppivotextended.py script, also available on GitHub
In the last post I explained the basic concept behind Pivot Tables and provided some examples. Pivot tables are an easy-to-use tool to derive some basic business intelligence from your data. As discussed last time, there are occasions when you’ll need to do interactive data mining by changing column and row fields. But in my experience, it’s handy to have my favorite reports built automatically, with the reports ready to go as soon as I open the spreadsheet. In this post I’ll develop and explain the code to create a set of pivot tables automatically in worksheet.
The goal of this exercise is to automate the generation of pivot tables from the last post, and save them to a new Excel file.
I started with the file newABCDCatering.xls from the previous post and record the macro to create this simple pivot table showing Net Bookings by Sales Rep and Food Name for the last four quarters.
Captured in Excel 2007, the recorded macro looks like this:
The post Mapping Excel VB Macros to Python covered a technique for recording a Visual Basic macro and porting it to Python. Using that approach, you could simply turn on the macro recorder and generate all the required tables, producing a long script with lots of redundancy. A better approach is to build a general purpose function that can be used over and over to generate the pivot tables.
Looking at the macro, you see lines specifying the Orientation of the field name, such as .Orientation = xlRowField and .Orientation = xlColumnField. A pivot table has four basic areas for fields:
Report Filter (.Orientation = xlPageField)
Column area (.Orientation = xlColumnField)
Row area (.Orientation = xlRowField)
Values area (PivotTables().AddDataField())
Each of these supports multiple fields (column fields for Sales Rep Name and Food Name were added in the example). The ordering of the fields changes the appearance of the table.
A general pattern should be apparent in this macro. First, the pivot table is created with the ActiveWorkbook.PivotCaches.Create() statement. Next, the columns and rows are configured with a series of ActiveSheet.PivotTables("PivotTable1").PivotFields() statements. Finally, the field used in the Values section of the table is configured using the ActiveSheet.PivotTables("PivotTable1").AddDataField statement. The general purpose function will need to contain all of these constructs. Note the parts that can’t be hard-coded: the source of the data, "Sheet2!R1C1:R791C13", and destination for the table, "Sheet3!R3C1" need to be determined based on the characteristics of the source data and can’t be hard coded in the general solution.
In Python, this pattern can be reduced to the following loop that covers fields for the Report Filter, Columns and Rows:
def addpivot(wb,sourcedata,title,filters=(),columns=(),
rows=(),sumvalue=(),sortfield=""):
"""Build a pivot table using the provided source location data
and specified fields
"""
...
for fieldlist,fieldc in ((filters,win32c.xlPageField),
(columns,win32c.xlColumnField),
(rows,win32c.xlRowField)):
for i,val in enumerate(fieldlist):
wb.ActiveSheet.PivotTables(tname).PivotFields(val).Orientation = fieldc
wb.ActiveSheet.PivotTables(tname).PivotFields(val).Position = i+1
...
Processing the Values field is more or less copied from the Visual Basic. To keep things simple in this example, this code is limited to adding “Sum of” values only, and doesn’t handle other Summarize Value functions such as Count, Min, Max, etc.
The actual values for filters, columns and rows in the function are defined in the call to the function. The complete function creates a new sheet within the workbook, then adds an empty pivot table to the sheet and builds the table using the field information provided. For example, to answer the question: What were the total sales in each of the last four quarters?, the pivot table is built with the following call to the addpivot function:
# What were the total sales in each of the last four quarters?
addpivot(wb,src,
title="Sales by Quarter",
filters=(),
columns=(),
rows=("Fiscal Quarter",),
sumvalue="Sum of Net Booking",
sortfield=())
which defines a pivot table using the row header “Fiscal Quarter” and data value “Sum of Net Booking”. The title “Sales by Quarter” is used to name the sheet itself.
To make the output spreadsheet more understandable, the title parameter passed into the function and used as a title in each worksheet and as the tab name.
The complete script is shown below. Caveats:
This script has been modified to run on both Excel 2007 and Excel 2003 and has been tested on those versions.
Adding pivot tables increases the size of the output Excel file, which can be mitigated by disabling caching of pivot table data. Line 48 of the script contains the command newsheet.PivotTables(tname).SaveData = False, which has been commented out. Uncommenting this command will reduce the size of the output Excel file, but will require that the pivot table be refreshed before use by clicking on Refresh Data on the PivotTable toolbar.