#!/usr/bin/env python3 import argparse import csv from functools import partial import matplotlib.pyplot as plt import numpy import re def readcsv(filename): """Read a (BlackBoard) CSV file into a header and data""" with open(filename, 'r') as csvfile: reader = csv.reader(csvfile, delimiter=',', quotechar='"') data = [] header = next(reader) for row in reader: data.append(row) return header, numpy.array(data) def parse_float(f): """Parse a string into a float and return 0. when that fails""" try: return float(f) except ValueError: return 0. def parse_floats(matrix): """Apply parse_float on all elements of a matrix""" m_new = [] for row in matrix: m_new.append([parse_float(v) for v in row]) return m_new def remove_zeros(matrix): """Remove zeros from a matrix Typically, BlackBoard grade lists will contain many zeros that we don't want to distort the boxplots. This function removes zeros on a per-column basis.""" m_new = [] for col in numpy.transpose(matrix): m_new.append([v for v in col if v != 0.0]) return numpy.transpose(m_new) def normalise(matrix): """Normalise grades to a 0-100 range Some grades are given in a 0-10 range, others in a 0-100. This function normalises *but these two* into a 0-100 range""" m_new = [] for row in matrix: m_new.append(row if max(row) > 10.0 else [10 * i for i in row]) return m_new def strip_header(h): """Strip common BlackBoard additions in the CSV header""" return h.split('instructor.download.column.total')[0] def remove_empty_lists(headers, data): """Remove empty columns and the corresponding headers from a matrix""" new_headers, new_data = [], [] for h, d in zip(headers, data): if d != []: new_headers.append(h) new_data.append(d) return new_headers, new_data def regex_callback(header, regex='', invert=False): """Check whether a regex occurs in a header This is an example of a possible callback function.""" match = re.compile(regex).search(header) != None return match != invert def check_callback(headers, data, header_callback): """For each header, check that we want to show it, and remove data if not""" new_headers, new_data = [], [] for h, d in zip(headers, data): if header_callback(h): new_headers.append(h) new_data.append(d) return new_headers, new_data def plotgrades(headers, data, header_callback=lambda x:True): """Plot grades corresponding to headers in a boxplot""" data = parse_floats(data) data = remove_zeros(data) headers, data = remove_empty_lists(headers, data) headers, data = check_callback(headers, data, header_callback) data = normalise(data) headers = [strip_header(h) for h in headers] if len(data) > 0: plt.boxplot(data) ax = plt.gca() plt.xticks(range(0, len(data) + 1), [''] + headers, rotation=90) ax.set_ylim([0,100]) plt.show() def parse_args(): """Parse command line arguments""" pars = argparse.ArgumentParser(description='Plot BlackBoard grades') pars.add_argument('-s', '--skip', metavar='n', type=int, default=0, help='Skip the first n columns') pars.add_argument('-w', '--where', metavar='regex', default='', help='Restrict what grades are shown with a regex') pars.add_argument('-i', '--invert', action='store_true', help='Invert --where regex') pars.add_argument('filename', metavar='file', help='The CSV file as exported by BlackBoard') return pars.parse_args() def main(): """Plot BlackBoard grades from a CSV file in boxplots""" args = parse_args() headers, data = readcsv(args.filename) plotgrades(headers[args.skip:], data[:,args.skip:], partial(regex_callback, regex=args.where, invert=args.invert)) if __name__ == '__main__': main()