comparison planemo/lib/python3.7/site-packages/docutils/parsers/rst/directives/tables.py @ 0:d30785e31577 draft

"planemo upload commit 6eee67778febed82ddd413c3ca40b3183a3898f1"
author guerler
date Fri, 31 Jul 2020 00:18:57 -0400
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:d30785e31577
1 # $Id: tables.py 8377 2019-08-27 19:49:45Z milde $
2 # Authors: David Goodger <goodger@python.org>; David Priest
3 # Copyright: This module has been placed in the public domain.
4
5 """
6 Directives for table elements.
7 """
8
9 __docformat__ = 'reStructuredText'
10
11
12 import sys
13 import os.path
14 import csv
15
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
21
22
23 def align(argument):
24 return directives.choice(argument, ('left', 'center', 'right'))
25
26
27 class Table(Directive):
28
29 """
30 Generic table base class.
31 """
32
33 optional_arguments = 1
34 final_argument_whitespace = True
35 option_spec = {'class': directives.class_option,
36 'name': directives.unchanged,
37 'align': align,
38 'width': directives.length_or_percentage_or_unitless,
39 'widths': directives.value_or(('auto', 'grid'),
40 directives.positive_int_list)}
41 has_content = True
42
43 def make_title(self):
44 if self.arguments:
45 title_text = self.arguments[0]
46 text_nodes, messages = self.state.inline_text(title_text,
47 self.lineno)
48 title = nodes.title(title_text, '', *text_nodes)
49 (title.source,
50 title.line) = self.state_machine.get_source_and_line(self.lineno)
51 else:
52 title = None
53 messages = []
54 return title, messages
55
56 def process_header_option(self):
57 source = self.state_machine.get_source(self.lineno - 1)
58 table_head = []
59 max_header_cols = 0
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(),
63 source)
64 table_head.extend(rows)
65 return table_head, max_header_cols
66
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)
82 for row in rows:
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)
97
98 def set_table_width(self, table_node):
99 if 'width' in self.options:
100 table_node['width'] = self.options.get('width')
101
102 @property
103 def widths(self):
104 return self.options.get('widths', '')
105
106 def get_column_widths(self, max_cols):
107 if isinstance(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
115 elif max_cols:
116 col_widths = [100 // max_cols] * max_cols
117 else:
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)
122 return col_widths
123
124 def extend_short_rows_with_empty_cells(self, columns, parts):
125 for part in parts:
126 for row in part:
127 if len(row) < columns:
128 row.extend([(0, 0, 0, [])] * (columns - len(row)))
129
130
131 class RSTTable(Table):
132
133 def run(self):
134 if not self.content:
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)
139 return [warning]
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)
148 return [error]
149 table_node = node[0]
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 isinstance(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)
168 if title:
169 table_node.insert(0, title)
170 return [table_node] + messages
171
172
173 class CSVTable(Table):
174
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,
186 'align': align,
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,}
195
196 class DocutilsDialect(csv.Dialect):
197
198 """CSV dialect for `csv_table` directive."""
199
200 delimiter = ','
201 quotechar = '"'
202 doublequote = True
203 skipinitialspace = True
204 strict = True
205 lineterminator = '\n'
206 quoting = csv.QUOTE_MINIMAL
207
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)
219
220
221 class HeaderDialect(csv.Dialect):
222
223 """CSV dialect to use for the "header" option data."""
224
225 delimiter = ','
226 quotechar = '"'
227 escapechar = '\\'
228 doublequote = False
229 skipinitialspace = True
230 strict = True
231 lineterminator = '\n'
232 quoting = csv.QUOTE_MINIMAL
233
234 def check_requirements(self):
235 pass
236
237 def run(self):
238 try:
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)
246 return [warning]
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 as detail:
263 return [detail.args[0]]
264 except csv.Error as detail:
265 message = str(detail)
266 if sys.version_info < (3, 0) 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)
272 return [error]
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)
281 if title:
282 table_node.insert(0, title)
283 return [table_node] + messages
284
285 def get_csv_data(self):
286 """
287 Get CSV data from the directive content, from an external
288 file, or from a URL reference.
289 """
290 encoding = self.options.get(
291 'encoding', self.state.document.settings.input_encoding)
292 error_handler = self.state.document.settings.input_encoding_error_handler
293 if self.content:
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),
310 line=self.lineno)
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)
317 try:
318 self.state.document.settings.record_dependencies.add(source)
319 csv_file = io.FileInput(source_path=source,
320 encoding=encoding,
321 error_handler=error_handler)
322 csv_data = csv_file.read().splitlines()
323 except IOError as 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),
328 line=self.lineno)
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.
335 if sys.version_info >= (3, 0):
336 from urllib.request import urlopen
337 from urllib.error import URLError
338 else:
339 from urllib2 import urlopen, URLError
340
341 source = self.options['url']
342 try:
343 csv_text = urlopen(source).read()
344 except (URLError, IOError, OSError, ValueError) as error:
345 severe = self.state_machine.reporter.severe(
346 'Problems with "%s" directive URL "%s":\n%s.'
347 % (self.name, self.options['url'], SafeString(error)),
348 nodes.literal_block(self.block_text, self.block_text),
349 line=self.lineno)
350 raise SystemMessagePropagation(severe)
351 csv_file = io.StringInput(
352 source=csv_text, source_path=source, encoding=encoding,
353 error_handler=(self.state.document.settings.\
354 input_encoding_error_handler))
355 csv_data = csv_file.read().splitlines()
356 else:
357 error = self.state_machine.reporter.warning(
358 'The "%s" directive requires content; none supplied.'
359 % self.name, nodes.literal_block(
360 self.block_text, self.block_text), line=self.lineno)
361 raise SystemMessagePropagation(error)
362 return csv_data, source
363
364 if sys.version_info < (3, 0):
365 # 2.x csv module doesn't do Unicode
366 def decode_from_csv(s):
367 return s.decode('utf-8')
368 def encode_for_csv(s):
369 return s.encode('utf-8')
370 else:
371 def decode_from_csv(s):
372 return s
373 def encode_for_csv(s):
374 return s
375 decode_from_csv = staticmethod(decode_from_csv)
376 encode_for_csv = staticmethod(encode_for_csv)
377
378 def parse_csv_data_into_rows(self, csv_data, dialect, source):
379 # csv.py doesn't do Unicode; encode temporarily as UTF-8
380 csv_reader = csv.reader([self.encode_for_csv(line + '\n')
381 for line in csv_data],
382 dialect=dialect)
383 rows = []
384 max_cols = 0
385 for row in csv_reader:
386 row_data = []
387 for cell in row:
388 # decode UTF-8 back to Unicode
389 cell_text = self.decode_from_csv(cell)
390 cell_data = (0, 0, 0, statemachine.StringList(
391 cell_text.splitlines(), source=source))
392 row_data.append(cell_data)
393 rows.append(row_data)
394 max_cols = max(max_cols, len(row))
395 return rows, max_cols
396
397
398 class ListTable(Table):
399
400 """
401 Implement tables whose data is encoded as a uniform two-level bullet list.
402 For further ideas, see
403 http://docutils.sf.net/docs/dev/rst/alternatives.html#list-driven-tables
404 """
405
406 option_spec = {'header-rows': directives.nonnegative_int,
407 'stub-columns': directives.nonnegative_int,
408 'width': directives.length_or_percentage_or_unitless,
409 'widths': directives.value_or(('auto', ),
410 directives.positive_int_list),
411 'class': directives.class_option,
412 'name': directives.unchanged,
413 'align': align}
414
415 def run(self):
416 if not self.content:
417 error = self.state_machine.reporter.error(
418 'The "%s" directive is empty; content required.' % self.name,
419 nodes.literal_block(self.block_text, self.block_text),
420 line=self.lineno)
421 return [error]
422 title, messages = self.make_title()
423 node = nodes.Element() # anonymous container for parsing
424 self.state.nested_parse(self.content, self.content_offset, node)
425 try:
426 num_cols, col_widths = self.check_list_content(node)
427 table_data = [[item.children for item in row_list[0]]
428 for row_list in node[0]]
429 header_rows = self.options.get('header-rows', 0)
430 stub_columns = self.options.get('stub-columns', 0)
431 self.check_table_dimensions(table_data, header_rows, stub_columns)
432 except SystemMessagePropagation as detail:
433 return [detail.args[0]]
434 table_node = self.build_table_from_list(table_data, col_widths,
435 header_rows, stub_columns)
436 if 'align' in self.options:
437 table_node['align'] = self.options.get('align')
438 table_node['classes'] += self.options.get('class', [])
439 self.set_table_width(table_node)
440 self.add_name(table_node)
441 if title:
442 table_node.insert(0, title)
443 return [table_node] + messages
444
445 def check_list_content(self, node):
446 if len(node) != 1 or not isinstance(node[0], nodes.bullet_list):
447 error = self.state_machine.reporter.error(
448 'Error parsing content block for the "%s" directive: '
449 'exactly one bullet list expected.' % self.name,
450 nodes.literal_block(self.block_text, self.block_text),
451 line=self.lineno)
452 raise SystemMessagePropagation(error)
453 list_node = node[0]
454 num_cols = 0
455 # Check for a uniform two-level bullet list:
456 for item_index in range(len(list_node)):
457 item = list_node[item_index]
458 if len(item) != 1 or not isinstance(item[0], nodes.bullet_list):
459 error = self.state_machine.reporter.error(
460 'Error parsing content block for the "%s" directive: '
461 'two-level bullet list expected, but row %s does not '
462 'contain a second-level bullet list.'
463 % (self.name, item_index + 1), nodes.literal_block(
464 self.block_text, self.block_text), line=self.lineno)
465 raise SystemMessagePropagation(error)
466 elif item_index:
467 if len(item[0]) != num_cols:
468 error = self.state_machine.reporter.error(
469 'Error parsing content block for the "%s" directive: '
470 'uniform two-level bullet list expected, but row %s '
471 'does not contain the same number of items as row 1 '
472 '(%s vs %s).'
473 % (self.name, item_index + 1, len(item[0]), num_cols),
474 nodes.literal_block(self.block_text, self.block_text),
475 line=self.lineno)
476 raise SystemMessagePropagation(error)
477 else:
478 num_cols = len(item[0])
479 col_widths = self.get_column_widths(num_cols)
480 return num_cols, col_widths
481
482 def build_table_from_list(self, table_data, col_widths, header_rows, stub_columns):
483 table = nodes.table()
484 if self.widths == 'auto':
485 table['classes'] += ['colwidths-auto']
486 elif self.widths: # "grid" or list of integers
487 table['classes'] += ['colwidths-given']
488 tgroup = nodes.tgroup(cols=len(col_widths))
489 table += tgroup
490 for col_width in col_widths:
491 colspec = nodes.colspec()
492 if col_width is not None:
493 colspec.attributes['colwidth'] = col_width
494 if stub_columns:
495 colspec.attributes['stub'] = 1
496 stub_columns -= 1
497 tgroup += colspec
498 rows = []
499 for row in table_data:
500 row_node = nodes.row()
501 for cell in row:
502 entry = nodes.entry()
503 entry += cell
504 row_node += entry
505 rows.append(row_node)
506 if header_rows:
507 thead = nodes.thead()
508 thead.extend(rows[:header_rows])
509 tgroup += thead
510 tbody = nodes.tbody()
511 tbody.extend(rows[header_rows:])
512 tgroup += tbody
513 return table