# coding: utf-8
from __future__ import unicode_literals, division
from collections import OrderedDict
from itertools import product
__all__ = (
'HORIZONTAL', 'VERTICAL',
'TableDict', 'HorizontalTableDict', 'VerticalTableDict', 'h', 'v',
'get_all_structures', 'build_table_dict', 'build_optimal_table_dict',
)
HORIZONTAL = 'horizontal'
VERTICAL = 'vertical'
def build_tag(name, props, content):
props_str = ' '.join('%s="%s"' % (k, v) for k, v in props.items())
return '<%s %s>%s</%s>' % (name, props_str, content, name)
[docs]class TableDict(OrderedDict):
"""
TableDict objects are ordered dicts with methods that renders HTML tables.
Here is an ugly schema to define the terms I’m using:
+---------+-------------+-------------+-------------+-------------+
| | hh1 | hh2 | hh3 | hh4 |
| +------+------+------+------+------+------+------+------+
| | hh11 | hh12 | hh21 | hh22 | hh31 | hh32 | hh41 | hh42 |
+=========+======+======+======+======+======+======+======+======+
| **vh1** | dA | dB | dC | dE | dF | dG | dH | dI |
+---------+------+------+------+------+------+------+------+------+
| **vh2** | dJ | dK | dL | dM | dN | dO | dP | dQ |
+---------+------+------+------+------+------+------+------+------+
| **vh3** | dR | dS | dT | dU | dV | dW | dX | dY |
+---------+------+------+------+------+------+------+------+------+
`hh1`, `hh11`, `hh2` […] are horizontal headers,
`vh1`, `vh2`, […] are vertical headers,
and `dA`, `dB`, […] are data.
"""
structure = ()
direction = None
def _get_headers(self, side):
"""
Builds a nested headers list based on the side of the headers.
This is a mix between lists and association lists, since we need to
know which header(s) are nested inside which header(s).
The returned value looks like:
['header1', 'header2', 'header3']
or
[['header1', ['header11', 'header12'], ['header2', ['header22']]]
:arg unicode side: Side of the headers, ``'vertical'``
or ``'horizontal'``.
:returns: A nested headers list.
:rtype: list
"""
DictClass = {HORIZONTAL: HorizontalTableDict,
VERTICAL: VerticalTableDict}[side]
headers = []
for k, v in self.items():
if isinstance(v, TableDict):
child_headers = getattr(v, side + '_headers')()
if child_headers:
if isinstance(self, DictClass):
headers.append([k, child_headers])
else:
for h in child_headers:
if h not in headers:
headers.append(h)
continue
if isinstance(self, DictClass) and k not in headers:
headers.append(k)
return headers
def horizontal_headers(self):
return self._get_headers(HORIZONTAL)
def vertical_headers(self):
return self._get_headers(VERTICAL)
@staticmethod
def _get_headers_depth(headers):
"""
Returns the max depth of ``headers``.
We cheat a bit, since the headers syntax is a mix between list and
association list.
:arg list headers: Can be gotten from ``TableDict._get_headers``.
:returns: The max depth of ``headers``.
:rtype: int
>>> TableDict._get_headers_depth([1, 2, 3])
1
>>> TableDict._get_headers_depth([[1, [11, 12], [2, [21, 22]]]])
2
>>> TableDict._get_headers_depth([[1, [[11, [111, 112]]]]])
3
"""
# Taken from http://stackoverflow.com/a/6039138/1576438
def max_depth(l):
return isinstance(l, (list, tuple, dict)) and max(map(max_depth, l)) + 1
if not headers:
return 1
d = max_depth(headers)
return d - d // 2
@classmethod
def _get_final_length(cls, l):
"""
Returns the total length of the deepest lists inside ``l``.
:arg list l: A nested list.
:returns: The total length of the deepest lists inside ``l``.
:rtype: int
>>> TableDict._get_final_length([['a', [1, 2, 3]], ['b', [4, 5]]])
5
"""
total = 0
for item in l:
if isinstance(item, (list, tuple)):
header, group = item
total += cls._get_final_length(group)
else:
total += 1
return total
def _horizontal_header_iterator(self, headers=None, depth=0):
"""
Returns a generator that iterates over horizontal headers.
This is designed to ease HTML generation.
"""
to_be_explored = []
headers = headers or self.horizontal_headers()
max_depth = self._get_headers_depth(self.horizontal_headers())
for item in headers:
group = None
if isinstance(item, list):
header, group = item
props = {'colspan': self._get_final_length(group)}
to_be_explored.extend(group)
else:
header = item
props = {}
if not group and depth <= max_depth:
props['rowspan'] = max_depth - depth
yield header, depth, props, not group
if to_be_explored:
for subheader, subdepth, subprops, is_leaf \
in self._horizontal_header_iterator(
to_be_explored, depth + 1):
yield subheader, subdepth, subprops, is_leaf
def _vertical_header_iterator(self, headers=None, depth=0):
"""
Returns a generator that iterates over vertical headers.
This is designed to ease HTML generation.
"""
headers = headers or self.vertical_headers()
max_depth = self._get_headers_depth(self.vertical_headers())
for item in headers:
group = None
if isinstance(item, list):
header, group = item
props = {'rowspan': self._get_final_length(group)}
else:
header = item
props = {}
if not group and depth <= max_depth:
props['colspan'] = max_depth - depth
yield header, depth, props, not group
if group:
for subheader, subdepth, subprops, is_leaf \
in self._vertical_header_iterator(group, depth + 1):
yield subheader, subdepth, subprops, is_leaf
def _accessors_iterator(self, headers, parent_accessors=()):
"""
Returns a generator that allows to iterate over accessors to pieces of
data.
It is used to iterate easily over data.
:arg list headers: Can be gotten from ``TableDict._get_headers``.
:arg tuple parent_accessors: Parent accessors of ``headers``. This is
only used internally for recursion.
:returns: A generator that iterates over tuples containing successive
accessors for a piece of data.
:rtype: generator of tuple
"""
for accessor in headers:
if isinstance(accessor, list):
header, group = accessor
for sub_accessor in self._accessors_iterator(
group, parent_accessors + (header,)):
yield sub_accessor
else:
yield parent_accessors + (accessor,)
def _horizontal_accessors(self):
if not hasattr(self, '__horizontal_accessors'):
self.__horizontal_accessors = list(
self._accessors_iterator(self.horizontal_headers()))
return self.__horizontal_accessors
def _vertical_accessors(self):
if not hasattr(self, '__vertical_accessors'):
self.__vertical_accessors = list(
self._accessors_iterator(self.vertical_headers()))
return self.__vertical_accessors
def _data_iterator(self):
"""
Returns a generator that iterates over data.
This is designed to ease HTML generation.
"""
def inner_generator(vertical_accessor, horizontal_accessor):
v = self
x, y = 0, 0
for table_class in self.structure:
try:
if table_class.direction == HORIZONTAL:
accessor = horizontal_accessor[x]
x += 1
else:
accessor = vertical_accessor[y]
y += 1
except IndexError:
break
try:
v = v[accessor]
except (TypeError, KeyError):
return
if isinstance(v, TableDict):
return
return v
vertical_accessors = self._vertical_accessors() or (None,)
horizontal_accessors = self._horizontal_accessors() or (None,)
for vertical_accessor in vertical_accessors:
for horizontal_accessor in horizontal_accessors:
yield inner_generator(vertical_accessor, horizontal_accessor)
def _get_data(self):
if not hasattr(self, '__data'):
self.__data = list(self._data_iterator())
return self.__data
[docs] def generate_html(self):
"""
Generates an HTML table from the contents of ``self``.
It creates an empty cell first, adds horizontal headers, then adds both
vertical headers and data in the same time.
This whole thing could be implemented using BeautifulSoup and its
outstanding inplace modification possibilities. That may be much more
readable, but also much slower.
:returns: A HTML table.
:rtype: unicode
"""
horizontal_headers = self.horizontal_headers()
vertical_headers = self.vertical_headers()
out = '<table>'
if horizontal_headers:
out += '<tr>'
if vertical_headers:
# Creates the top left empty cell.
out += '<td colspan="%s" rowspan="%s" ' \
'style="border: none;"></td>' \
% (self._get_headers_depth(vertical_headers),
self._get_headers_depth(horizontal_headers))
# Creates horizontal headers.
previous_depth = 0
for header, depth, props, is_leaf \
in self._horizontal_header_iterator(horizontal_headers):
if depth != previous_depth:
out += '</tr><tr>'
previous_depth = depth
out += build_tag('th', props, header)
horizontal_length = self._get_final_length(horizontal_headers) or 1
def display_data(data_index=0):
out = ''
for data in self._get_data()[
horizontal_length * data_index:
horizontal_length * (data_index + 1)]:
out += '<td>%s</td>' % ('-' if data is None else data)
return out
# Creates lines of vertical headers and data.
if vertical_headers:
previous_depth = 0
data_index = -1
for header, depth, props, is_leaf \
in self._vertical_header_iterator(vertical_headers):
if depth <= previous_depth:
out += '</tr><tr>'
data_index += 1
out += build_tag('th', props, header)
if is_leaf:
out += display_data(data_index)
previous_depth = depth
else:
out += '</tr><tr>' + display_data() + '</tr>'
out += '</table>'
return out
[docs] def get_ugliness(self):
"""
Returns the ugliness of the current table.
The uglier a table is, the less readable it becomes.
:returns: The ugliness of the current table.
:rtype: int
"""
vertical_length = self._get_final_length(self.vertical_headers())
horizontal_length = self._get_final_length(self.horizontal_headers())
ugliness = vertical_length + horizontal_length
ugliness += abs(vertical_length - horizontal_length)
return ugliness
class HorizontalTableDictMeta(type):
def __repr__(cls):
return 'h'
class VerticalTableDictMeta(type):
def __repr__(cls):
return 'v'
[docs]class HorizontalTableDict(TableDict):
"""
Same as :class:`TableDict`, but with a direction.
The direction is used to specify whether the headers of the first depth
of the current object should be put on the horizontal or vertical axis.
"""
__metaclass__ = HorizontalTableDictMeta
direction = HORIZONTAL
[docs]class VerticalTableDict(TableDict):
__doc__ = HorizontalTableDict.__doc__
__metaclass__ = VerticalTableDictMeta
direction = VERTICAL
h = HorizontalTableDict
v = VerticalTableDict
[docs]def get_all_structures(datadict):
"""
Returns all the possible structures for a datadict.
:arg datadict: Nested dicts or association lists. Association lists have
the advantage of being ordered.
:returns: All possible structures.
:rtype: list
"""
return list(product((v, h), repeat=TableDict._get_headers_depth(datadict)))
[docs]def build_table_dict(datadict, structure):
"""
Automatically builds a TableDict from ``datadict`` and ``structure``.
:arg datadict: Nested dicts or association lists. Association lists have
the advantage of being ordered.
:type datadict: dict or tuple or list
:arg structure: Structure of the headers of the returned object. This
must be a sequence of ``h`` and/or ``v``,
one per depth level of ``datadict``.
:type structure: list or tuple
:returns: Nested :class:`TableDict` s with horizontal and/or vertical
structures applied, according to ``structure``.
:rtype: HorizontalTableDict or VerticalTableDict
"""
def apply_structure(datadict, structure, level=0):
datadict = structure[level](datadict)
for k, v in datadict.items():
if isinstance(v, tuple):
datadict[k] = apply_structure(v, structure, level + 1)
return datadict
new = apply_structure(datadict, structure)
new.structure = structure
return new
[docs]def build_optimal_table_dict(datadict):
"""
Automatically builds the less ugly table possible from ``datadict``.
:arg datadict: Nested dicts or association lists. Association lists have
the advantage of being ordered.
:type datadict: dict or tuple or list
:returns: Nested :class:`TableDict` with horizontal and/or vertical
structures applied, according to ``structure``.
:rtype: HorizontalTableDict or VerticalTableDict
"""
structures = get_all_structures(datadict)
tables = [build_table_dict(datadict, structure)
for structure in structures]
return sorted(tables, key=lambda t: t.get_ugliness())[0]