2 # Authors: David Goodger <goodger@python.org>; David Priest
3 # Copyright: This module has been placed in the public domain.
6 Directives for table elements.
9 __docformat__
= 'reStructuredText'
16 from docutils
import io
, nodes
, statemachine
, utils
17 from docutils
.utils
.error_reporting
import SafeString
18 from docutils
.utils
import SystemMessagePropagation
19 from docutils
.parsers
.rst
import Directive
20 from docutils
.parsers
.rst
import directives
24 return directives
.choice(argument
, ('left', 'center', 'right'))
27 class Table(Directive
):
30 Generic table base class.
33 optional_arguments
= 1
34 final_argument_whitespace
= True
35 option_spec
= {'class': directives
.class_option
,
36 'name': directives
.unchanged
,
38 'width': directives
.length_or_percentage_or_unitless
,
39 'widths': directives
.value_or(('auto', 'grid'),
40 directives
.positive_int_list
)}
45 title_text
= self
.arguments
[0]
46 text_nodes
, messages
= self
.state
.inline_text(title_text
,
48 title
= nodes
.title(title_text
, '', *text_nodes
)
50 title
.line
) = self
.state_machine
.get_source_and_line(self
.lineno
)
54 return title
, messages
56 def process_header_option(self
):
57 source
= self
.state_machine
.get_source(self
.lineno
- 1)
60 if 'header' in self
.options
: # separate table header in option
61 rows
, max_header_cols
= self
.parse_csv_data_into_rows(
62 self
.options
['header'].split('\n'), self
.HeaderDialect(),
64 table_head
.extend(rows
)
65 return table_head
, max_header_cols
67 def check_table_dimensions(self
, rows
, header_rows
, stub_columns
):
68 if len(rows
) < header_rows
:
69 error
= self
.state_machine
.reporter
.error(
70 '%s header row(s) specified but only %s row(s) of data '
71 'supplied ("%s" directive).'
72 % (header_rows
, len(rows
), self
.name
), nodes
.literal_block(
73 self
.block_text
, self
.block_text
), line
=self
.lineno
)
74 raise SystemMessagePropagation(error
)
75 if len(rows
) == header_rows
> 0:
76 error
= self
.state_machine
.reporter
.error(
77 'Insufficient data supplied (%s row(s)); no data remaining '
78 'for table body, required by "%s" directive.'
79 % (len(rows
), self
.name
), nodes
.literal_block(
80 self
.block_text
, self
.block_text
), line
=self
.lineno
)
81 raise SystemMessagePropagation(error
)
83 if len(row
) < stub_columns
:
84 error
= self
.state_machine
.reporter
.error(
85 '%s stub column(s) specified but only %s columns(s) of '
86 'data supplied ("%s" directive).' %
87 (stub_columns
, len(row
), self
.name
), nodes
.literal_block(
88 self
.block_text
, self
.block_text
), line
=self
.lineno
)
89 raise SystemMessagePropagation(error
)
90 if len(row
) == stub_columns
> 0:
91 error
= self
.state_machine
.reporter
.error(
92 'Insufficient data supplied (%s columns(s)); no data remaining '
93 'for table body, required by "%s" directive.'
94 % (len(row
), self
.name
), nodes
.literal_block(
95 self
.block_text
, self
.block_text
), line
=self
.lineno
)
96 raise SystemMessagePropagation(error
)
98 def set_table_width(self
, table_node
):
99 if 'width' in self
.options
:
100 table_node
['width'] = self
.options
.get('width')
104 return self
.options
.get('widths', '')
106 def get_column_widths(self
, max_cols
):
107 if type(self
.widths
) == list:
108 if len(self
.widths
) != max_cols
:
109 error
= self
.state_machine
.reporter
.error(
110 '"%s" widths do not match the number of columns in table '
111 '(%s).' % (self
.name
, max_cols
), nodes
.literal_block(
112 self
.block_text
, self
.block_text
), line
=self
.lineno
)
113 raise SystemMessagePropagation(error
)
114 col_widths
= self
.widths
116 col_widths
= [100 // max_cols
] * max_cols
118 error
= self
.state_machine
.reporter
.error(
119 'No table data detected in CSV file.', nodes
.literal_block(
120 self
.block_text
, self
.block_text
), line
=self
.lineno
)
121 raise SystemMessagePropagation(error
)
124 def extend_short_rows_with_empty_cells(self
, columns
, parts
):
127 if len(row
) < columns
:
128 row
.extend([(0, 0, 0, [])] * (columns
- len(row
)))
131 class RSTTable(Table
):
135 warning
= self
.state_machine
.reporter
.warning(
136 'Content block expected for the "%s" directive; none found.'
137 % self
.name
, nodes
.literal_block(
138 self
.block_text
, self
.block_text
), line
=self
.lineno
)
140 title
, messages
= self
.make_title()
141 node
= nodes
.Element() # anonymous container for parsing
142 self
.state
.nested_parse(self
.content
, self
.content_offset
, node
)
143 if len(node
) != 1 or not isinstance(node
[0], nodes
.table
):
144 error
= self
.state_machine
.reporter
.error(
145 'Error parsing content block for the "%s" directive: exactly '
146 'one table expected.' % self
.name
, nodes
.literal_block(
147 self
.block_text
, self
.block_text
), line
=self
.lineno
)
150 table_node
['classes'] += self
.options
.get('class', [])
151 self
.set_table_width(table_node
)
152 if 'align' in self
.options
:
153 table_node
['align'] = self
.options
.get('align')
154 tgroup
= table_node
[0]
155 if type(self
.widths
) == list:
156 colspecs
= [child
for child
in tgroup
.children
157 if child
.tagname
== 'colspec']
158 for colspec
, col_width
in zip(colspecs
, self
.widths
):
159 colspec
['colwidth'] = col_width
160 # @@@ the colwidths argument for <tgroup> is not part of the
161 # XML Exchange Table spec (https://www.oasis-open.org/specs/tm9901.htm)
162 # and hence violates the docutils.dtd.
163 if self
.widths
== 'auto':
164 table_node
['classes'] += ['colwidths-auto']
165 elif self
.widths
: # "grid" or list of integers
166 table_node
['classes'] += ['colwidths-given']
167 self
.add_name(table_node
)
169 table_node
.insert(0, title
)
170 return [table_node
] + messages
173 class CSVTable(Table
):
175 option_spec
= {'header-rows': directives
.nonnegative_int
,
176 'stub-columns': directives
.nonnegative_int
,
177 'header': directives
.unchanged
,
178 'width': directives
.length_or_percentage_or_unitless
,
179 'widths': directives
.value_or(('auto', ),
180 directives
.positive_int_list
),
181 'file': directives
.path
,
182 'url': directives
.uri
,
183 'encoding': directives
.encoding
,
184 'class': directives
.class_option
,
185 'name': directives
.unchanged
,
187 # field delimiter char
188 'delim': directives
.single_char_or_whitespace_or_unicode
,
189 # treat whitespace after delimiter as significant
190 'keepspace': directives
.flag
,
191 # text field quote/unquote char:
192 'quote': directives
.single_char_or_unicode
,
193 # char used to escape delim & quote as-needed:
194 'escape': directives
.single_char_or_unicode
,}
196 class DocutilsDialect(csv
.Dialect
):
198 """CSV dialect for `csv_table` directive."""
203 skipinitialspace
= True
205 lineterminator
= '\n'
206 quoting
= csv
.QUOTE_MINIMAL
208 def __init__(self
, options
):
209 if 'delim' in options
:
210 self
.delimiter
= CSVTable
.encode_for_csv(options
['delim'])
211 if 'keepspace' in options
:
212 self
.skipinitialspace
= False
213 if 'quote' in options
:
214 self
.quotechar
= CSVTable
.encode_for_csv(options
['quote'])
215 if 'escape' in options
:
216 self
.doublequote
= False
217 self
.escapechar
= CSVTable
.encode_for_csv(options
['escape'])
218 csv
.Dialect
.__init
__(self
)
221 class HeaderDialect(csv
.Dialect
):
223 """CSV dialect to use for the "header" option data."""
229 skipinitialspace
= True
231 lineterminator
= '\n'
232 quoting
= csv
.QUOTE_MINIMAL
234 def check_requirements(self
):
239 if (not self
.state
.document
.settings
.file_insertion_enabled
240 and ('file' in self
.options
241 or 'url' in self
.options
)):
242 warning
= self
.state_machine
.reporter
.warning(
243 'File and URL access deactivated; ignoring "%s" '
244 'directive.' % self
.name
, nodes
.literal_block(
245 self
.block_text
, self
.block_text
), line
=self
.lineno
)
247 self
.check_requirements()
248 title
, messages
= self
.make_title()
249 csv_data
, source
= self
.get_csv_data()
250 table_head
, max_header_cols
= self
.process_header_option()
251 rows
, max_cols
= self
.parse_csv_data_into_rows(
252 csv_data
, self
.DocutilsDialect(self
.options
), source
)
253 max_cols
= max(max_cols
, max_header_cols
)
254 header_rows
= self
.options
.get('header-rows', 0)
255 stub_columns
= self
.options
.get('stub-columns', 0)
256 self
.check_table_dimensions(rows
, header_rows
, stub_columns
)
257 table_head
.extend(rows
[:header_rows
])
258 table_body
= rows
[header_rows
:]
259 col_widths
= self
.get_column_widths(max_cols
)
260 self
.extend_short_rows_with_empty_cells(max_cols
,
261 (table_head
, table_body
))
262 except SystemMessagePropagation
, detail
:
263 return [detail
.args
[0]]
264 except csv
.Error
, detail
:
265 message
= str(detail
)
266 if sys
.version_info
< (3,) and '1-character string' in message
:
267 message
+= '\nwith Python 2.x this must be an ASCII character.'
268 error
= self
.state_machine
.reporter
.error(
269 'Error with CSV data in "%s" directive:\n%s'
270 % (self
.name
, message
), nodes
.literal_block(
271 self
.block_text
, self
.block_text
), line
=self
.lineno
)
273 table
= (col_widths
, table_head
, table_body
)
274 table_node
= self
.state
.build_table(table
, self
.content_offset
,
275 stub_columns
, widths
=self
.widths
)
276 table_node
['classes'] += self
.options
.get('class', [])
277 if 'align' in self
.options
:
278 table_node
['align'] = self
.options
.get('align')
279 self
.set_table_width(table_node
)
280 self
.add_name(table_node
)
282 table_node
.insert(0, title
)
283 return [table_node
] + messages
285 def get_csv_data(self
):
287 Get CSV data from the directive content, from an external
288 file, or from a URL reference.
290 encoding
= self
.options
.get(
291 'encoding', self
.state
.document
.settings
.input_encoding
)
292 error_handler
= self
.state
.document
.settings
.input_encoding_error_handler
294 # CSV data is from directive content.
295 if 'file' in self
.options
or 'url' in self
.options
:
296 error
= self
.state_machine
.reporter
.error(
297 '"%s" directive may not both specify an external file and'
298 ' have content.' % self
.name
, nodes
.literal_block(
299 self
.block_text
, self
.block_text
), line
=self
.lineno
)
300 raise SystemMessagePropagation(error
)
301 source
= self
.content
.source(0)
302 csv_data
= self
.content
303 elif 'file' in self
.options
:
304 # CSV data is from an external file.
305 if 'url' in self
.options
:
306 error
= self
.state_machine
.reporter
.error(
307 'The "file" and "url" options may not be simultaneously'
308 ' specified for the "%s" directive.' % self
.name
,
309 nodes
.literal_block(self
.block_text
, self
.block_text
),
311 raise SystemMessagePropagation(error
)
312 source_dir
= os
.path
.dirname(
313 os
.path
.abspath(self
.state
.document
.current_source
))
314 source
= os
.path
.normpath(os
.path
.join(source_dir
,
315 self
.options
['file']))
316 source
= utils
.relative_path(None, source
)
318 self
.state
.document
.settings
.record_dependencies
.add(source
)
319 csv_file
= io
.FileInput(source_path
=source
,
321 error_handler
=error_handler
)
322 csv_data
= csv_file
.read().splitlines()
323 except IOError, error
:
324 severe
= self
.state_machine
.reporter
.severe(
325 u
'Problems with "%s" directive path:\n%s.'
326 % (self
.name
, SafeString(error
)),
327 nodes
.literal_block(self
.block_text
, self
.block_text
),
329 raise SystemMessagePropagation(severe
)
330 elif 'url' in self
.options
:
331 # CSV data is from a URL.
332 # Do not import urllib2 at the top of the module because
333 # it may fail due to broken SSL dependencies, and it takes
334 # about 0.15 seconds to load.
336 source
= self
.options
['url']
338 csv_text
= urllib2
.urlopen(source
).read()
339 except (urllib2
.URLError
, IOError, OSError, ValueError), error
:
340 severe
= self
.state_machine
.reporter
.severe(
341 'Problems with "%s" directive URL "%s":\n%s.'
342 % (self
.name
, self
.options
['url'], SafeString(error
)),
343 nodes
.literal_block(self
.block_text
, self
.block_text
),
345 raise SystemMessagePropagation(severe
)
346 csv_file
= io
.StringInput(
347 source
=csv_text
, source_path
=source
, encoding
=encoding
,
348 error_handler
=(self
.state
.document
.settings
.\
349 input_encoding_error_handler
))
350 csv_data
= csv_file
.read().splitlines()
352 error
= self
.state_machine
.reporter
.warning(
353 'The "%s" directive requires content; none supplied.'
354 % self
.name
, nodes
.literal_block(
355 self
.block_text
, self
.block_text
), line
=self
.lineno
)
356 raise SystemMessagePropagation(error
)
357 return csv_data
, source
359 if sys
.version_info
< (3,):
360 # 2.x csv module doesn't do Unicode
361 def decode_from_csv(s
):
362 return s
.decode('utf-8')
363 def encode_for_csv(s
):
364 return s
.encode('utf-8')
366 def decode_from_csv(s
):
368 def encode_for_csv(s
):
370 decode_from_csv
= staticmethod(decode_from_csv
)
371 encode_for_csv
= staticmethod(encode_for_csv
)
373 def parse_csv_data_into_rows(self
, csv_data
, dialect
, source
):
374 # csv.py doesn't do Unicode; encode temporarily as UTF-8
375 csv_reader
= csv
.reader([self
.encode_for_csv(line
+ '\n')
376 for line
in csv_data
],
380 for row
in csv_reader
:
383 # decode UTF-8 back to Unicode
384 cell_text
= self
.decode_from_csv(cell
)
385 cell_data
= (0, 0, 0, statemachine
.StringList(
386 cell_text
.splitlines(), source
=source
))
387 row_data
.append(cell_data
)
388 rows
.append(row_data
)
389 max_cols
= max(max_cols
, len(row
))
390 return rows
, max_cols
393 class ListTable(Table
):
396 Implement tables whose data is encoded as a uniform two-level bullet list.
397 For further ideas, see
398 http://docutils.sf.net/docs/dev/rst/alternatives.html#list-driven-tables
401 option_spec
= {'header-rows': directives
.nonnegative_int
,
402 'stub-columns': directives
.nonnegative_int
,
403 'width': directives
.length_or_percentage_or_unitless
,
404 'widths': directives
.value_or(('auto', ),
405 directives
.positive_int_list
),
406 'class': directives
.class_option
,
407 'name': directives
.unchanged
,
412 error
= self
.state_machine
.reporter
.error(
413 'The "%s" directive is empty; content required.' % self
.name
,
414 nodes
.literal_block(self
.block_text
, self
.block_text
),
417 title
, messages
= self
.make_title()
418 node
= nodes
.Element() # anonymous container for parsing
419 self
.state
.nested_parse(self
.content
, self
.content_offset
, node
)
421 num_cols
, col_widths
= self
.check_list_content(node
)
422 table_data
= [[item
.children
for item
in row_list
[0]]
423 for row_list
in node
[0]]
424 header_rows
= self
.options
.get('header-rows', 0)
425 stub_columns
= self
.options
.get('stub-columns', 0)
426 self
.check_table_dimensions(table_data
, header_rows
, stub_columns
)
427 except SystemMessagePropagation
, detail
:
428 return [detail
.args
[0]]
429 table_node
= self
.build_table_from_list(table_data
, col_widths
,
430 header_rows
, stub_columns
)
431 if 'align' in self
.options
:
432 table_node
['align'] = self
.options
.get('align')
433 table_node
['classes'] += self
.options
.get('class', [])
434 self
.set_table_width(table_node
)
435 self
.add_name(table_node
)
437 table_node
.insert(0, title
)
438 return [table_node
] + messages
440 def check_list_content(self
, node
):
441 if len(node
) != 1 or not isinstance(node
[0], nodes
.bullet_list
):
442 error
= self
.state_machine
.reporter
.error(
443 'Error parsing content block for the "%s" directive: '
444 'exactly one bullet list expected.' % self
.name
,
445 nodes
.literal_block(self
.block_text
, self
.block_text
),
447 raise SystemMessagePropagation(error
)
449 # Check for a uniform two-level bullet list:
450 for item_index
in range(len(list_node
)):
451 item
= list_node
[item_index
]
452 if len(item
) != 1 or not isinstance(item
[0], nodes
.bullet_list
):
453 error
= self
.state_machine
.reporter
.error(
454 'Error parsing content block for the "%s" directive: '
455 'two-level bullet list expected, but row %s does not '
456 'contain a second-level bullet list.'
457 % (self
.name
, item_index
+ 1), nodes
.literal_block(
458 self
.block_text
, self
.block_text
), line
=self
.lineno
)
459 raise SystemMessagePropagation(error
)
461 # ATTN pychecker users: num_cols is guaranteed to be set in the
462 # "else" clause below for item_index==0, before this branch is
464 if len(item
[0]) != num_cols
:
465 error
= self
.state_machine
.reporter
.error(
466 'Error parsing content block for the "%s" directive: '
467 'uniform two-level bullet list expected, but row %s '
468 'does not contain the same number of items as row 1 '
470 % (self
.name
, item_index
+ 1, len(item
[0]), num_cols
),
471 nodes
.literal_block(self
.block_text
, self
.block_text
),
473 raise SystemMessagePropagation(error
)
475 num_cols
= len(item
[0])
476 col_widths
= self
.get_column_widths(num_cols
)
477 return num_cols
, col_widths
479 def build_table_from_list(self
, table_data
, col_widths
, header_rows
, stub_columns
):
480 table
= nodes
.table()
481 if self
.widths
== 'auto':
482 table
['classes'] += ['colwidths-auto']
483 elif self
.widths
: # "grid" or list of integers
484 table
['classes'] += ['colwidths-given']
485 tgroup
= nodes
.tgroup(cols
=len(col_widths
))
487 for col_width
in col_widths
:
488 colspec
= nodes
.colspec()
489 if col_width
is not None:
490 colspec
.attributes
['colwidth'] = col_width
492 colspec
.attributes
['stub'] = 1
496 for row
in table_data
:
497 row_node
= nodes
.row()
499 entry
= nodes
.entry()
502 rows
.append(row_node
)
504 thead
= nodes
.thead()
505 thead
.extend(rows
[:header_rows
])
507 tbody
= nodes
.tbody()
508 tbody
.extend(rows
[header_rows
:])