Package logilab-common-0 ::
Package 39 ::
Package 0 ::
Module table
|
|
1 """Table management module.
2
3 :copyright: 2000-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
4 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
5 :license: General Public License version 2 - http://www.gnu.org/licenses
6 """
7 __docformat__ = "restructuredtext en"
8
9 from warnings import warn
10
11 from logilab.common.compat import enumerate, sum, set
12
14 """Table defines a data table with column and row names.
15 inv:
16 len(self.data) <= len(self.row_names)
17 forall(self.data, lambda x: len(x) <= len(self.col_names))
18 """
19
20 - def __init__(self, default_value=0, col_names=None, row_names=None):
21 self.col_names = []
22 self.row_names = []
23 self.data = []
24 self.default_value = default_value
25 if col_names:
26 self.create_columns(col_names)
27 if row_names:
28 self.create_rows(row_names)
29
31 return 'row%s' % (len(self.row_names)+1)
32
34 return iter(self.data)
35
37 if other is None:
38 return False
39 else:
40 return list(self) == list(other)
41
43 return not self == other
44
46 return len(self.row_names)
47
48
50 """Appends row_names to the list of existing rows
51 """
52 self.row_names.extend(row_names)
53 for row_name in row_names:
54 self.data.append([self.default_value]*len(self.col_names))
55
57 """Appends col_names to the list of existing columns
58 """
59 for col_name in col_names:
60 self.create_column(col_name)
61
63 """Creates a rowname to the row_names list
64 """
65 row_name = row_name or self._next_row_name()
66 self.row_names.append(row_name)
67 self.data.append([self.default_value]*len(self.col_names))
68
69
71 """Creates a colname to the col_names list
72 """
73 self.col_names.append(col_name)
74 for row in self.data:
75 row.append(self.default_value)
76
77
79 """Sorts the table (in-place) according to data stored in col_id
80 """
81 try:
82 col_index = self.col_names.index(col_id)
83 self.sort_by_column_index(col_index, method)
84 except ValueError:
85 raise KeyError("Col (%s) not found in table" % (col_id))
86
87
89 """Sorts the table 'in-place' according to data stored in col_index
90
91 method should be in ('asc', 'desc')
92 """
93 sort_list = [(row[col_index], row, row_name)
94 for row, row_name in zip(self.data, self.row_names)]
95
96 sort_list.sort()
97
98 if method.lower() == 'desc':
99 sort_list.reverse()
100
101
102 self.data = []
103 self.row_names = []
104 for val, row, row_name in sort_list:
105 self.data.append(row)
106 self.row_names.append(row_name)
107
108 - def groupby(self, colname, *others):
109 """builds indexes of data
110 :returns: nested dictionnaries pointing to actual rows
111 """
112 groups = {}
113 colnames = (colname,) + others
114 col_indexes = [self.col_names.index(col_id) for col_id in colnames]
115 for row in self.data:
116 ptr = groups
117 for col_index in col_indexes[:-1]:
118 ptr = ptr.setdefault(row[col_index], {})
119 ptr = ptr.setdefault(row[col_indexes[-1]],
120 Table(default_value=self.default_value,
121 col_names=self.col_names))
122 ptr.append_row(tuple(row))
123 return groups
124
125 - def select(self, colname, value):
126 grouped = self.groupby(colname)
127 try:
128 return grouped[value]
129 except KeyError:
130 return []
131
132 - def remove(self, colname, value):
133 col_index = self.col_names.index(colname)
134 for row in self.data[:]:
135 if row[col_index] == value:
136 self.data.remove(row)
137
138
139
140 - def set_cell(self, row_index, col_index, data):
141 """sets value of cell 'row_indew', 'col_index' to data
142 """
143 self.data[row_index][col_index] = data
144
145
147 """sets value of cell mapped by row_id and col_id to data
148 Raises a KeyError if row_id or col_id are not found in the table
149 """
150 try:
151 row_index = self.row_names.index(row_id)
152 except ValueError:
153 raise KeyError("Row (%s) not found in table" % (row_id))
154 else:
155 try:
156 col_index = self.col_names.index(col_id)
157 self.data[row_index][col_index] = data
158 except ValueError:
159 raise KeyError("Column (%s) not found in table" % (col_id))
160
161
162 - def set_row(self, row_index, row_data):
163 """sets the 'row_index' row
164 pre:
165 type(row_data) == types.ListType
166 len(row_data) == len(self.col_names)
167 """
168 self.data[row_index] = row_data
169
170
172 """sets the 'row_id' column
173 pre:
174 type(row_data) == types.ListType
175 len(row_data) == len(self.row_names)
176 Raises a KeyError if row_id is not found
177 """
178 try:
179 row_index = self.row_names.index(row_id)
180 self.set_row(row_index, row_data)
181 except ValueError:
182 raise KeyError('Row (%s) not found in table' % (row_id))
183
184
186 """Appends a row to the table
187 pre:
188 type(row_data) == types.ListType
189 len(row_data) == len(self.col_names)
190 """
191 row_name = row_name or self._next_row_name()
192 self.row_names.append(row_name)
193 self.data.append(row_data)
194 return len(self.data) - 1
195
196 - def insert_row(self, index, row_data, row_name=None):
197 """Appends row_data before 'index' in the table. To make 'insert'
198 behave like 'list.insert', inserting in an out of range index will
199 insert row_data to the end of the list
200 pre:
201 type(row_data) == types.ListType
202 len(row_data) == len(self.col_names)
203 """
204 row_name = row_name or self._next_row_name()
205 self.row_names.insert(index, row_name)
206 self.data.insert(index, row_data)
207
208
210 """Deletes the 'index' row in the table, and returns it.
211 Raises an IndexError if index is out of range
212 """
213 self.row_names.pop(index)
214 return self.data.pop(index)
215
216
218 """Deletes the 'row_id' row in the table.
219 Raises a KeyError if row_id was not found.
220 """
221 try:
222 row_index = self.row_names.index(row_id)
223 self.delete_row(row_index)
224 except ValueError:
225 raise KeyError('Row (%s) not found in table' % (row_id))
226
227
229 """sets the 'col_index' column
230 pre:
231 type(col_data) == types.ListType
232 len(col_data) == len(self.row_names)
233 """
234
235 for row_index, cell_data in enumerate(col_data):
236 self.data[row_index][col_index] = cell_data
237
238
240 """sets the 'col_id' column
241 pre:
242 type(col_data) == types.ListType
243 len(col_data) == len(self.col_names)
244 Raises a KeyError if col_id is not found
245 """
246 try:
247 col_index = self.col_names.index(col_id)
248 self.set_column(col_index, col_data)
249 except ValueError:
250 raise KeyError('Column (%s) not found in table' % (col_id))
251
252
254 """Appends the 'col_index' column
255 pre:
256 type(col_data) == types.ListType
257 len(col_data) == len(self.row_names)
258 """
259 self.col_names.append(col_name)
260 for row_index, cell_data in enumerate(col_data):
261 self.data[row_index].append(cell_data)
262
263
265 """Appends col_data before 'index' in the table. To make 'insert'
266 behave like 'list.insert', inserting in an out of range index will
267 insert col_data to the end of the list
268 pre:
269 type(col_data) == types.ListType
270 len(col_data) == len(self.row_names)
271 """
272 self.col_names.insert(index, col_name)
273 for row_index, cell_data in enumerate(col_data):
274 self.data[row_index].insert(index, cell_data)
275
276
278 """Deletes the 'index' column in the table, and returns it.
279 Raises an IndexError if index is out of range
280 """
281 self.col_names.pop(index)
282 return [row.pop(index) for row in self.data]
283
284
286 """Deletes the 'col_id' col in the table.
287 Raises a KeyError if col_id was not found.
288 """
289 try:
290 col_index = self.col_names.index(col_id)
291 self.delete_column(col_index)
292 except ValueError:
293 raise KeyError('Column (%s) not found in table' % (col_id))
294
295
296
297
299 """Returns a tuple which represents the table's shape
300 """
301 return len(self.row_names), len(self.col_names)
302 shape = property(get_shape)
303
305 """provided for convenience"""
306 rows, multirows = None, False
307 cols, multicols = None, False
308 if isinstance(indices, tuple):
309 rows = indices[0]
310 if len(indices) > 1:
311 cols = indices[1]
312 else:
313 rows = indices
314
315 if isinstance(rows,str):
316 try:
317 rows = self.row_names.index(rows)
318 except ValueError:
319 raise KeyError("Row (%s) not found in table" % (rows))
320 if isinstance(rows,int):
321 rows = slice(rows,rows+1)
322 multirows = False
323 else:
324 rows = slice(None)
325 multirows = True
326
327 if isinstance(cols,str):
328 try:
329 cols = self.col_names.index(cols)
330 except ValueError:
331 raise KeyError("Column (%s) not found in table" % (cols))
332 if isinstance(cols,int):
333 cols = slice(cols,cols+1)
334 multicols = False
335 else:
336 cols = slice(None)
337 multicols = True
338
339 tab = Table()
340 tab.default_value = self.default_value
341 tab.create_rows(self.row_names[rows])
342 tab.create_columns(self.col_names[cols])
343 for idx,row in enumerate(self.data[rows]):
344 tab.set_row(idx, row[cols])
345 if multirows :
346 if multicols:
347 return tab
348 else:
349 return [item[0] for item in tab.data]
350 else:
351 if multicols:
352 return tab.data[0]
353 else:
354 return tab.data[0][0]
355
357 """Returns a tuple which represents the table's shape
358 """
359 warn('table.get_dimensions() is deprecated, use table.shape instead',
360 DeprecationWarning, stacklevel=2)
361 return self.shape
362
364 """Returns the element at [row_index][col_index]
365 """
366 warn('Table.get_element() is deprecated, use Table.get_cell instead',
367 DeprecationWarning, stacklevel=2)
368 return self.data[row_index][col_index]
369
370 - def get_cell(self, row_index, col_index):
371 warn('table.get_cell(i,j) is deprecated, use table[i,j] instead',
372 DeprecationWarning, stacklevel=2)
373 return self.data[row_index][col_index]
374
376 """Returns the element at [row_id][col_id]
377 """
378
379
380 try:
381 row_index = self.row_names.index(row_id)
382 except ValueError:
383 raise KeyError("Row (%s) not found in table" % (row_id))
384 else:
385 try:
386 col_index = self.col_names.index(col_id)
387 except ValueError:
388 raise KeyError("Column (%s) not found in table" % (col_id))
389 return self.data[row_index][col_index]
390
392 """Returns the 'row_index' row
393 """
394 warn('table.get_row(i) is deprecated, use table[i] instead',
395 DeprecationWarning, stacklevel=2)
396 return self.data[row_index]
397
399 """Returns the 'row_id' row
400 """
401
402
403 try:
404 row_index = self.row_names.index(row_id)
405 except ValueError:
406 raise KeyError("Row (%s) not found in table" % (row_id))
407 return self.data[row_index]
408
410 """Returns the 'col_index' col
411 """
412 warn('table.get_column(i) is deprecated, use table[:,i] instead',
413 DeprecationWarning, stacklevel=2)
414 col = [row[col_index] for row in self.data]
415 if distinct:
416 return set(col)
417 else:
418 return col
419
421 """Returns the 'col_id' col
422 """
423
424
425 try:
426 col_index = self.col_names.index(col_id)
427 except ValueError:
428 raise KeyError("Column (%s) not found in table" % (col_id))
429 return self.get_column(col_index, distinct)
430
431
433 """Returns all the rows in the table
434 """
435 warn('table.get_rows() is deprecated, just iterate over table instead',
436 DeprecationWarning, stacklevel=2)
437 return self.data
438
439
441 """Returns all the columns in the table
442 """
443 return [self[:,index] for index in range(len(self.col_names))]
444
445
447 """Applies the stylesheet to this table
448 """
449 for instruction in stylesheet.instructions:
450 eval(instruction)
451
452
454 """Keeps the self object intact, and returns the transposed (rotated)
455 table.
456 """
457 transposed = Table()
458 transposed.create_rows(self.col_names)
459 transposed.create_columns(self.row_names)
460 for col_index, column in enumerate(self.get_columns()):
461 transposed.set_row(col_index, column)
462 return transposed
463
464
466 """returns a string representing the table in a pretty
467 printed 'text' format.
468 """
469
470 max_row_name = 0
471 for row_name in self.row_names:
472 if len(row_name) > max_row_name:
473 max_row_name = len(row_name)
474 col_start = max_row_name + 5
475
476 lines = []
477
478
479 col_names_line = [' '*col_start]
480 for col_name in self.col_names:
481 col_names_line.append(col_name.encode('iso-8859-1') + ' '*5)
482 lines.append('|' + '|'.join(col_names_line) + '|')
483 max_line_length = len(lines[0])
484
485
486 for row_index, row in enumerate(self.data):
487 line = []
488
489 row_name = self.row_names[row_index].encode('iso-8859-1')
490 line.append(row_name + ' '*(col_start-len(row_name)))
491
492
493 for col_index, cell in enumerate(row):
494 col_name_length = len(self.col_names[col_index]) + 5
495 data = str(cell)
496 line.append(data + ' '*(col_name_length - len(data)))
497 lines.append('|' + '|'.join(line) + '|')
498 if len(lines[-1]) > max_line_length:
499 max_line_length = len(lines[-1])
500
501
502 lines.insert(0, '-'*max_line_length)
503 lines.append('-'*max_line_length)
504 return '\n'.join(lines)
505
506
508 return repr(self.data)
509
511 data = []
512
513 for row in self.data:
514 data.append([str(cell) for cell in row])
515 lines = ['\t'.join(row) for row in data]
516 return '\n'.join(lines)
517
518
519
521 """Defines a table's style
522 """
523
525
526 self._table = table
527 self.size = dict([(col_name,'1*') for col_name in table.col_names])
528
529
530 self.size['__row_column__'] = '1*'
531 self.alignment = dict([(col_name,'right')
532 for col_name in table.col_names])
533 self.alignment['__row_column__'] = 'right'
534
535
536
537 self.units = dict([(col_name,'') for col_name in table.col_names])
538 self.units['__row_column__'] = ''
539
540
542 """sets the size of the specified col_id to value
543 """
544 self.size[col_id] = value
545
547 """Allows to set the size according to the column index rather than
548 using the column's id.
549 BE CAREFUL : the '0' column is the '__row_column__' one !
550 """
551 if col_index == 0:
552 col_id = '__row_column__'
553 else:
554 col_id = self._table.col_names[col_index-1]
555
556 self.size[col_id] = value
557
558
560 """sets the alignment of the specified col_id to value
561 """
562 self.alignment[col_id] = value
563
564
566 """Allows to set the alignment according to the column index rather than
567 using the column's id.
568 BE CAREFUL : the '0' column is the '__row_column__' one !
569 """
570 if col_index == 0:
571 col_id = '__row_column__'
572 else:
573 col_id = self._table.col_names[col_index-1]
574
575 self.alignment[col_id] = value
576
577
579 """sets the unit of the specified col_id to value
580 """
581 self.units[col_id] = value
582
583
585 """Allows to set the unit according to the column index rather than
586 using the column's id.
587 BE CAREFUL : the '0' column is the '__row_column__' one !
588 (Note that in the 'unit' case, you shouldn't have to set a unit
589 for the 1st column (the __row__column__ one))
590 """
591 if col_index == 0:
592 col_id = '__row_column__'
593 else:
594 col_id = self._table.col_names[col_index-1]
595
596 self.units[col_id] = value
597
598
600 """Returns the size of the specified col_id
601 """
602 return self.size[col_id]
603
604
606 """Allows to get the size according to the column index rather than
607 using the column's id.
608 BE CAREFUL : the '0' column is the '__row_column__' one !
609 """
610 if col_index == 0:
611 col_id = '__row_column__'
612 else:
613 col_id = self._table.col_names[col_index-1]
614
615 return self.size[col_id]
616
617
619 """Returns the alignment of the specified col_id
620 """
621 return self.alignment[col_id]
622
623
625 """Allors to get the alignment according to the column index rather than
626 using the column's id.
627 BE CAREFUL : the '0' column is the '__row_column__' one !
628 """
629 if col_index == 0:
630 col_id = '__row_column__'
631 else:
632 col_id = self._table.col_names[col_index-1]
633
634 return self.alignment[col_id]
635
636
638 """Returns the unit of the specified col_id
639 """
640 return self.units[col_id]
641
642
644 """Allors to get the unit according to the column index rather than
645 using the column's id.
646 BE CAREFUL : the '0' column is the '__row_column__' one !
647 """
648 if col_index == 0:
649 col_id = '__row_column__'
650 else:
651 col_id = self._table.col_names[col_index-1]
652
653 return self.units[col_id]
654
655
656 import re
657 CELL_PROG = re.compile("([0-9]+)_([0-9]+)")
658
660 """A simple Table stylesheet
661 Rules are expressions where cells are defined by the row_index
662 and col_index separated by an underscore ('_').
663 For example, suppose you want to say that the (2,5) cell must be
664 the sum of its two preceding cells in the row, you would create
665 the following rule :
666 2_5 = 2_3 + 2_4
667 You can also use all the math.* operations you want. For example:
668 2_5 = sqrt(2_3**2 + 2_4**2)
669 """
670
672 rules = rules or []
673 self.rules = []
674 self.instructions = []
675 for rule in rules:
676 self.add_rule(rule)
677
678
680 """Adds a rule to the stylesheet rules
681 """
682 try:
683 source_code = ['from math import *']
684 source_code.append(CELL_PROG.sub(r'self.data[\1][\2]', rule))
685 self.instructions.append(compile('\n'.join(source_code),
686 'table.py', 'exec'))
687 self.rules.append(rule)
688 except SyntaxError:
689 print "Bad Stylesheet Rule : %s [skipped]"%rule
690
691
693 """Creates and adds a rule to sum over the row at row_index from
694 start_col to end_col.
695 dest_cell is a tuple of two elements (x,y) of the destination cell
696 No check is done for indexes ranges.
697 pre:
698 start_col >= 0
699 end_col > start_col
700 """
701 cell_list = ['%d_%d'%(row_index, index) for index in range(start_col,
702 end_col + 1)]
703 rule = '%d_%d=' % dest_cell + '+'.join(cell_list)
704 self.add_rule(rule)
705
706
708 """Creates and adds a rule to make the row average (from start_col
709 to end_col)
710 dest_cell is a tuple of two elements (x,y) of the destination cell
711 No check is done for indexes ranges.
712 pre:
713 start_col >= 0
714 end_col > start_col
715 """
716 cell_list = ['%d_%d'%(row_index, index) for index in range(start_col,
717 end_col + 1)]
718 num = (end_col - start_col + 1)
719 rule = '%d_%d=' % dest_cell + '('+'+'.join(cell_list)+')/%f'%num
720 self.add_rule(rule)
721
722
724 """Creates and adds a rule to sum over the col at col_index from
725 start_row to end_row.
726 dest_cell is a tuple of two elements (x,y) of the destination cell
727 No check is done for indexes ranges.
728 pre:
729 start_row >= 0
730 end_row > start_row
731 """
732 cell_list = ['%d_%d'%(index, col_index) for index in range(start_row,
733 end_row + 1)]
734 rule = '%d_%d=' % dest_cell + '+'.join(cell_list)
735 self.add_rule(rule)
736
737
739 """Creates and adds a rule to make the col average (from start_row
740 to end_row)
741 dest_cell is a tuple of two elements (x,y) of the destination cell
742 No check is done for indexes ranges.
743 pre:
744 start_row >= 0
745 end_row > start_row
746 """
747 cell_list = ['%d_%d'%(index, col_index) for index in range(start_row,
748 end_row + 1)]
749 num = (end_row - start_row + 1)
750 rule = '%d_%d=' % dest_cell + '('+'+'.join(cell_list)+')/%f'%num
751 self.add_rule(rule)
752
753
754
756 """Defines a simple text renderer
757 """
758
760 """keywords should be properties with an associated boolean as value.
761 For example :
762 renderer = TableCellRenderer(units = True, alignment = False)
763 An unspecified property will have a 'False' value by default.
764 Possible properties are :
765 alignment, unit
766 """
767 self.properties = properties
768
769
770 - def render_cell(self, cell_coord, table, table_style):
771 """Renders the cell at 'cell_coord' in the table, using table_style
772 """
773 row_index, col_index = cell_coord
774 cell_value = table.data[row_index][col_index]
775 final_content = self._make_cell_content(cell_value,
776 table_style, col_index +1)
777 return self._render_cell_content(final_content,
778 table_style, col_index + 1)
779
780
782 """Renders the cell for 'row_id' row
783 """
784 cell_value = row_name.encode('iso-8859-1')
785 return self._render_cell_content(cell_value, table_style, 0)
786
787
789 """Renders the cell for 'col_id' row
790 """
791 cell_value = col_name.encode('iso-8859-1')
792 col_index = table.col_names.index(col_name)
793 return self._render_cell_content(cell_value, table_style, col_index +1)
794
795
796
797 - def _render_cell_content(self, content, table_style, col_index):
798 """Makes the appropriate rendering for this cell content.
799 Rendering properties will be searched using the
800 *table_style.get_xxx_by_index(col_index)' methods
801
802 **This method should be overridden in the derived renderer classes.**
803 """
804 return content
805
806
807 - def _make_cell_content(self, cell_content, table_style, col_index):
808 """Makes the cell content (adds decoration data, like units for
809 example)
810 """
811 final_content = cell_content
812 if 'skip_zero' in self.properties:
813 replacement_char = self.properties['skip_zero']
814 else:
815 replacement_char = 0
816 if replacement_char and final_content == 0:
817 return replacement_char
818
819 try:
820 units_on = self.properties['units']
821 if units_on:
822 final_content = self._add_unit(
823 cell_content, table_style, col_index)
824 except KeyError:
825 pass
826
827 return final_content
828
829
830 - def _add_unit(self, cell_content, table_style, col_index):
831 """Adds unit to the cell_content if needed
832 """
833 unit = table_style.get_unit_by_index(col_index)
834 return str(cell_content) + " " + unit
835
836
837
839 """Defines how to render a cell for a docboook table
840 """
841
843 """Computes the colspec element according to the style
844 """
845 size = table_style.get_size_by_index(col_index)
846 return '<colspec colname="c%d" colwidth="%s"/>\n' % \
847 (col_index, size)
848
849
850 - def _render_cell_content(self, cell_content, table_style, col_index):
851 """Makes the appropriate rendering for this cell content.
852 Rendering properties will be searched using the
853 table_style.get_xxx_by_index(col_index)' methods.
854 """
855 try:
856 align_on = self.properties['alignment']
857 alignment = table_style.get_alignment_by_index(col_index)
858 if align_on:
859 return "<entry align='%s'>%s</entry>\n" % \
860 (alignment, cell_content)
861 except KeyError:
862
863 return "<entry>%s</entry>\n" % cell_content
864
865
867 """A class to write tables
868 """
869
870 - def __init__(self, stream, table, style, **properties):
871 self._stream = stream
872 self.style = style or TableStyle(table)
873 self._table = table
874 self.properties = properties
875 self.renderer = None
876
877
879 """sets the table's associated style
880 """
881 self.style = style
882
883
885 """sets the way to render cell
886 """
887 self.renderer = renderer
888
889
891 """Updates writer's properties (for cell rendering)
892 """
893 self.properties.update(properties)
894
895
897 """Writes the table
898 """
899 raise NotImplementedError("write_table must be implemented !")
900
901
902
904 """Defines an implementation of TableWriter to write a table in Docbook
905 """
906
908 """Writes col headers
909 """
910
911 for col_index in range(len(self._table.col_names)+1):
912 self._stream.write(self.renderer.define_col_header(col_index,
913 self.style))
914
915 self._stream.write("<thead>\n<row>\n")
916
917 self._stream.write('<entry></entry>\n')
918 for col_name in self._table.col_names:
919 self._stream.write(self.renderer.render_col_cell(
920 col_name, self._table,
921 self.style))
922
923 self._stream.write("</row>\n</thead>\n")
924
925
926 - def _write_body(self):
927 """Writes the table body
928 """
929 self._stream.write('<tbody>\n')
930
931 for row_index, row in enumerate(self._table.data):
932 self._stream.write('<row>\n')
933 row_name = self._table.row_names[row_index]
934
935 self._stream.write(self.renderer.render_row_cell(row_name,
936 self._table,
937 self.style))
938
939 for col_index, cell in enumerate(row):
940 self._stream.write(self.renderer.render_cell(
941 (row_index, col_index),
942 self._table, self.style))
943
944 self._stream.write('</row>\n')
945
946 self._stream.write('</tbody>\n')
947
948
950 """Writes the table
951 """
952 self._stream.write('<table>\n<title>%s></title>\n'%(title))
953 self._stream.write(
954 '<tgroup cols="%d" align="left" colsep="1" rowsep="1">\n'%
955 (len(self._table.col_names)+1))
956 self._write_headers()
957 self._write_body()
958
959 self._stream.write('</tgroup>\n</table>\n')
960