misc.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. # $Id: misc.py 9048 2022-03-29 21:50:15Z milde $
  2. # Authors: David Goodger <goodger@python.org>; Dethe Elza
  3. # Copyright: This module has been placed in the public domain.
  4. """Miscellaneous directives."""
  5. __docformat__ = 'reStructuredText'
  6. import os.path
  7. import re
  8. import time
  9. from docutils import io, nodes, statemachine, utils
  10. from docutils.parsers.rst import Directive, convert_directive_function
  11. from docutils.parsers.rst import directives, roles, states
  12. from docutils.parsers.rst.directives.body import CodeBlock, NumberLines
  13. from docutils.transforms import misc
  14. class Include(Directive):
  15. """
  16. Include content read from a separate source file.
  17. Content may be parsed by the parser, or included as a literal
  18. block. The encoding of the included file can be specified. Only
  19. a part of the given file argument may be included by specifying
  20. start and end line or text to match before and/or after the text
  21. to be used.
  22. """
  23. required_arguments = 1
  24. optional_arguments = 0
  25. final_argument_whitespace = True
  26. option_spec = {'literal': directives.flag,
  27. 'code': directives.unchanged,
  28. 'encoding': directives.encoding,
  29. 'parser': directives.parser_name,
  30. 'tab-width': int,
  31. 'start-line': int,
  32. 'end-line': int,
  33. 'start-after': directives.unchanged_required,
  34. 'end-before': directives.unchanged_required,
  35. # ignored except for 'literal' or 'code':
  36. 'number-lines': directives.unchanged, # integer or None
  37. 'class': directives.class_option,
  38. 'name': directives.unchanged}
  39. standard_include_path = os.path.join(os.path.dirname(states.__file__),
  40. 'include')
  41. def run(self):
  42. """Include a file as part of the content of this reST file.
  43. Depending on the options, the file (or a clipping) is
  44. converted to nodes and returned or inserted into the input stream.
  45. """
  46. if not self.state.document.settings.file_insertion_enabled:
  47. raise self.warning('"%s" directive disabled.' % self.name)
  48. source = self.state_machine.input_lines.source(
  49. self.lineno - self.state_machine.input_offset - 1)
  50. source_dir = os.path.dirname(os.path.abspath(source))
  51. path = directives.path(self.arguments[0])
  52. if path.startswith('<') and path.endswith('>'):
  53. path = os.path.join(self.standard_include_path, path[1:-1])
  54. path = os.path.normpath(os.path.join(source_dir, path))
  55. path = utils.relative_path(None, path)
  56. encoding = self.options.get(
  57. 'encoding', self.state.document.settings.input_encoding)
  58. e_handler = self.state.document.settings.input_encoding_error_handler
  59. tab_width = self.options.get(
  60. 'tab-width', self.state.document.settings.tab_width)
  61. try:
  62. include_file = io.FileInput(source_path=path,
  63. encoding=encoding,
  64. error_handler=e_handler)
  65. except UnicodeEncodeError:
  66. raise self.severe('Problems with "%s" directive path:\n'
  67. 'Cannot encode input file path "%s" '
  68. '(wrong locale?).' %
  69. (self.name, path))
  70. except OSError as error:
  71. raise self.severe('Problems with "%s" directive path:\n%s.' %
  72. (self.name, io.error_string(error)))
  73. else:
  74. self.state.document.settings.record_dependencies.add(path)
  75. # Get to-be-included content
  76. startline = self.options.get('start-line', None)
  77. endline = self.options.get('end-line', None)
  78. try:
  79. if startline or (endline is not None):
  80. lines = include_file.readlines()
  81. rawtext = ''.join(lines[startline:endline])
  82. else:
  83. rawtext = include_file.read()
  84. except UnicodeError as error:
  85. raise self.severe('Problem with "%s" directive:\n%s' %
  86. (self.name, io.error_string(error)))
  87. # start-after/end-before: no restrictions on newlines in match-text,
  88. # and no restrictions on matching inside lines vs. line boundaries
  89. after_text = self.options.get('start-after', None)
  90. if after_text:
  91. # skip content in rawtext before *and incl.* a matching text
  92. after_index = rawtext.find(after_text)
  93. if after_index < 0:
  94. raise self.severe('Problem with "start-after" option of "%s" '
  95. 'directive:\nText not found.' % self.name)
  96. rawtext = rawtext[after_index + len(after_text):]
  97. before_text = self.options.get('end-before', None)
  98. if before_text:
  99. # skip content in rawtext after *and incl.* a matching text
  100. before_index = rawtext.find(before_text)
  101. if before_index < 0:
  102. raise self.severe('Problem with "end-before" option of "%s" '
  103. 'directive:\nText not found.' % self.name)
  104. rawtext = rawtext[:before_index]
  105. include_lines = statemachine.string2lines(rawtext, tab_width,
  106. convert_whitespace=True)
  107. for i, line in enumerate(include_lines):
  108. if len(line) > self.state.document.settings.line_length_limit:
  109. raise self.warning('"%s": line %d exceeds the'
  110. ' line-length-limit.' % (path, i+1))
  111. if 'literal' in self.options:
  112. # Don't convert tabs to spaces, if `tab_width` is negative.
  113. if tab_width >= 0:
  114. text = rawtext.expandtabs(tab_width)
  115. else:
  116. text = rawtext
  117. literal_block = nodes.literal_block(
  118. rawtext, source=path,
  119. classes=self.options.get('class', []))
  120. literal_block.line = 1
  121. self.add_name(literal_block)
  122. if 'number-lines' in self.options:
  123. try:
  124. startline = int(self.options['number-lines'] or 1)
  125. except ValueError:
  126. raise self.error(':number-lines: with non-integer '
  127. 'start value')
  128. endline = startline + len(include_lines)
  129. if text.endswith('\n'):
  130. text = text[:-1]
  131. tokens = NumberLines([([], text)], startline, endline)
  132. for classes, value in tokens:
  133. if classes:
  134. literal_block += nodes.inline(value, value,
  135. classes=classes)
  136. else:
  137. literal_block += nodes.Text(value)
  138. else:
  139. literal_block += nodes.Text(text)
  140. return [literal_block]
  141. if 'code' in self.options:
  142. self.options['source'] = path
  143. # Don't convert tabs to spaces, if `tab_width` is negative:
  144. if tab_width < 0:
  145. include_lines = rawtext.splitlines()
  146. codeblock = CodeBlock(self.name,
  147. [self.options.pop('code')], # arguments
  148. self.options,
  149. include_lines, # content
  150. self.lineno,
  151. self.content_offset,
  152. self.block_text,
  153. self.state,
  154. self.state_machine)
  155. return codeblock.run()
  156. # Prevent circular inclusion:
  157. clip_options = (startline, endline, before_text, after_text)
  158. include_log = self.state.document.include_log
  159. # log entries are tuples (<source>, <clip-options>)
  160. if not include_log: # new document
  161. include_log.append((utils.relative_path(None, source),
  162. (None, None, None, None)))
  163. if (path, clip_options) in include_log:
  164. master_paths = (pth for (pth, opt) in reversed(include_log))
  165. inclusion_chain = '\n> '.join((path, *master_paths))
  166. raise self.warning('circular inclusion in "%s" directive:\n%s'
  167. % (self.name, inclusion_chain))
  168. if 'parser' in self.options:
  169. # parse into a dummy document and return created nodes
  170. document = utils.new_document(path, self.state.document.settings)
  171. document.include_log = include_log + [(path, clip_options)]
  172. parser = self.options['parser']()
  173. parser.parse('\n'.join(include_lines), document)
  174. # clean up doctree and complete parsing
  175. document.transformer.populate_from_components((parser,))
  176. document.transformer.apply_transforms()
  177. return document.children
  178. # Include as rST source:
  179. #
  180. # mark end (cf. parsers.rst.states.Body.comment())
  181. include_lines += ['', '.. end of inclusion from "%s"' % path]
  182. self.state_machine.insert_input(include_lines, path)
  183. # update include-log
  184. include_log.append((path, clip_options))
  185. return []
  186. class Raw(Directive):
  187. """
  188. Pass through content unchanged
  189. Content is included in output based on type argument
  190. Content may be included inline (content section of directive) or
  191. imported from a file or url.
  192. """
  193. required_arguments = 1
  194. optional_arguments = 0
  195. final_argument_whitespace = True
  196. option_spec = {'file': directives.path,
  197. 'url': directives.uri,
  198. 'encoding': directives.encoding,
  199. 'class': directives.class_option}
  200. has_content = True
  201. def run(self):
  202. if (not self.state.document.settings.raw_enabled
  203. or (not self.state.document.settings.file_insertion_enabled
  204. and ('file' in self.options
  205. or 'url' in self.options))):
  206. raise self.warning('"%s" directive disabled.' % self.name)
  207. attributes = {'format': ' '.join(self.arguments[0].lower().split())}
  208. encoding = self.options.get(
  209. 'encoding', self.state.document.settings.input_encoding)
  210. e_handler = self.state.document.settings.input_encoding_error_handler
  211. if self.content:
  212. if 'file' in self.options or 'url' in self.options:
  213. raise self.error(
  214. '"%s" directive may not both specify an external file '
  215. 'and have content.' % self.name)
  216. text = '\n'.join(self.content)
  217. elif 'file' in self.options:
  218. if 'url' in self.options:
  219. raise self.error(
  220. 'The "file" and "url" options may not be simultaneously '
  221. 'specified for the "%s" directive.' % self.name)
  222. source_dir = os.path.dirname(
  223. os.path.abspath(self.state.document.current_source))
  224. path = os.path.normpath(os.path.join(source_dir,
  225. self.options['file']))
  226. path = utils.relative_path(None, path)
  227. try:
  228. raw_file = io.FileInput(source_path=path,
  229. encoding=encoding,
  230. error_handler=e_handler)
  231. except OSError as error:
  232. raise self.severe('Problems with "%s" directive path:\n%s.'
  233. % (self.name, io.error_string(error)))
  234. else:
  235. # TODO: currently, raw input files are recorded as
  236. # dependencies even if not used for the chosen output format.
  237. self.state.document.settings.record_dependencies.add(path)
  238. try:
  239. text = raw_file.read()
  240. except UnicodeError as error:
  241. raise self.severe('Problem with "%s" directive:\n%s'
  242. % (self.name, io.error_string(error)))
  243. attributes['source'] = path
  244. elif 'url' in self.options:
  245. source = self.options['url']
  246. # Do not import urllib at the top of the module because
  247. # it may fail due to broken SSL dependencies, and it takes
  248. # about 0.15 seconds to load. Update: < 0.03s with Py3k.
  249. from urllib.request import urlopen
  250. from urllib.error import URLError
  251. try:
  252. raw_text = urlopen(source).read()
  253. except (URLError, OSError) as error:
  254. raise self.severe('Problems with "%s" directive URL "%s":\n%s.'
  255. % (self.name,
  256. self.options['url'],
  257. io.error_string(error)))
  258. raw_file = io.StringInput(source=raw_text, source_path=source,
  259. encoding=encoding,
  260. error_handler=e_handler)
  261. try:
  262. text = raw_file.read()
  263. except UnicodeError as error:
  264. raise self.severe('Problem with "%s" directive:\n%s'
  265. % (self.name, io.error_string(error)))
  266. attributes['source'] = source
  267. else:
  268. # This will always fail because there is no content.
  269. self.assert_has_content()
  270. raw_node = nodes.raw('', text, classes=self.options.get('class', []),
  271. **attributes)
  272. (raw_node.source,
  273. raw_node.line) = self.state_machine.get_source_and_line(self.lineno)
  274. return [raw_node]
  275. class Replace(Directive):
  276. has_content = True
  277. def run(self):
  278. if not isinstance(self.state, states.SubstitutionDef):
  279. raise self.error(
  280. 'Invalid context: the "%s" directive can only be used within '
  281. 'a substitution definition.' % self.name)
  282. self.assert_has_content()
  283. text = '\n'.join(self.content)
  284. element = nodes.Element(text)
  285. self.state.nested_parse(self.content, self.content_offset,
  286. element)
  287. # element might contain [paragraph] + system_message(s)
  288. node = None
  289. messages = []
  290. for elem in element:
  291. if not node and isinstance(elem, nodes.paragraph):
  292. node = elem
  293. elif isinstance(elem, nodes.system_message):
  294. elem['backrefs'] = []
  295. messages.append(elem)
  296. else:
  297. return [
  298. self.reporter.error(
  299. f'Error in "{self.name}" directive: may contain '
  300. 'a single paragraph only.', line=self.lineno)]
  301. if node:
  302. return messages + node.children
  303. return messages
  304. class Unicode(Directive):
  305. r"""
  306. Convert Unicode character codes (numbers) to characters. Codes may be
  307. decimal numbers, hexadecimal numbers (prefixed by ``0x``, ``x``, ``\x``,
  308. ``U+``, ``u``, or ``\u``; e.g. ``U+262E``), or XML-style numeric character
  309. entities (e.g. ``&#x262E;``). Text following ".." is a comment and is
  310. ignored. Spaces are ignored, and any other text remains as-is.
  311. """
  312. required_arguments = 1
  313. optional_arguments = 0
  314. final_argument_whitespace = True
  315. option_spec = {'trim': directives.flag,
  316. 'ltrim': directives.flag,
  317. 'rtrim': directives.flag}
  318. comment_pattern = re.compile(r'( |\n|^)\.\. ')
  319. def run(self):
  320. if not isinstance(self.state, states.SubstitutionDef):
  321. raise self.error(
  322. 'Invalid context: the "%s" directive can only be used within '
  323. 'a substitution definition.' % self.name)
  324. substitution_definition = self.state_machine.node
  325. if 'trim' in self.options:
  326. substitution_definition.attributes['ltrim'] = 1
  327. substitution_definition.attributes['rtrim'] = 1
  328. if 'ltrim' in self.options:
  329. substitution_definition.attributes['ltrim'] = 1
  330. if 'rtrim' in self.options:
  331. substitution_definition.attributes['rtrim'] = 1
  332. codes = self.comment_pattern.split(self.arguments[0])[0].split()
  333. element = nodes.Element()
  334. for code in codes:
  335. try:
  336. decoded = directives.unicode_code(code)
  337. except ValueError as error:
  338. raise self.error('Invalid character code: %s\n%s'
  339. % (code, io.error_string(error)))
  340. element += nodes.Text(decoded)
  341. return element.children
  342. class Class(Directive):
  343. """
  344. Set a "class" attribute on the directive content or the next element.
  345. When applied to the next element, a "pending" element is inserted, and a
  346. transform does the work later.
  347. """
  348. required_arguments = 1
  349. optional_arguments = 0
  350. final_argument_whitespace = True
  351. has_content = True
  352. def run(self):
  353. try:
  354. class_value = directives.class_option(self.arguments[0])
  355. except ValueError:
  356. raise self.error(
  357. 'Invalid class attribute value for "%s" directive: "%s".'
  358. % (self.name, self.arguments[0]))
  359. node_list = []
  360. if self.content:
  361. container = nodes.Element()
  362. self.state.nested_parse(self.content, self.content_offset,
  363. container)
  364. for node in container:
  365. node['classes'].extend(class_value)
  366. node_list.extend(container.children)
  367. else:
  368. pending = nodes.pending(
  369. misc.ClassAttribute,
  370. {'class': class_value, 'directive': self.name},
  371. self.block_text)
  372. self.state_machine.document.note_pending(pending)
  373. node_list.append(pending)
  374. return node_list
  375. class Role(Directive):
  376. has_content = True
  377. argument_pattern = re.compile(r'(%s)\s*(\(\s*(%s)\s*\)\s*)?$'
  378. % ((states.Inliner.simplename,) * 2))
  379. def run(self):
  380. """Dynamically create and register a custom interpreted text role."""
  381. if self.content_offset > self.lineno or not self.content:
  382. raise self.error('"%s" directive requires arguments on the first '
  383. 'line.' % self.name)
  384. args = self.content[0]
  385. match = self.argument_pattern.match(args)
  386. if not match:
  387. raise self.error('"%s" directive arguments not valid role names: '
  388. '"%s".' % (self.name, args))
  389. new_role_name = match.group(1)
  390. base_role_name = match.group(3)
  391. messages = []
  392. if base_role_name:
  393. base_role, messages = roles.role(
  394. base_role_name, self.state_machine.language, self.lineno,
  395. self.state.reporter)
  396. if base_role is None:
  397. error = self.state.reporter.error(
  398. 'Unknown interpreted text role "%s".' % base_role_name,
  399. nodes.literal_block(self.block_text, self.block_text),
  400. line=self.lineno)
  401. return messages + [error]
  402. else:
  403. base_role = roles.generic_custom_role
  404. assert not hasattr(base_role, 'arguments'), (
  405. 'Supplemental directive arguments for "%s" directive not '
  406. 'supported (specified by "%r" role).' % (self.name, base_role))
  407. try:
  408. converted_role = convert_directive_function(base_role)
  409. (arguments, options, content, content_offset
  410. ) = self.state.parse_directive_block(
  411. self.content[1:], self.content_offset,
  412. converted_role, option_presets={})
  413. except states.MarkupError as detail:
  414. error = self.reporter.error(
  415. 'Error in "%s" directive:\n%s.' % (self.name, detail),
  416. nodes.literal_block(self.block_text, self.block_text),
  417. line=self.lineno)
  418. return messages + [error]
  419. if 'class' not in options:
  420. try:
  421. options['class'] = directives.class_option(new_role_name)
  422. except ValueError as detail:
  423. error = self.reporter.error(
  424. 'Invalid argument for "%s" directive:\n%s.'
  425. % (self.name, detail),
  426. nodes.literal_block(self.block_text, self.block_text),
  427. line=self.lineno)
  428. return messages + [error]
  429. role = roles.CustomRole(new_role_name, base_role, options, content)
  430. roles.register_local_role(new_role_name, role)
  431. return messages
  432. class DefaultRole(Directive):
  433. """Set the default interpreted text role."""
  434. optional_arguments = 1
  435. final_argument_whitespace = False
  436. def run(self):
  437. if not self.arguments:
  438. if '' in roles._roles:
  439. # restore the "default" default role
  440. del roles._roles['']
  441. return []
  442. role_name = self.arguments[0]
  443. role, messages = roles.role(role_name, self.state_machine.language,
  444. self.lineno, self.state.reporter)
  445. if role is None:
  446. error = self.state.reporter.error(
  447. 'Unknown interpreted text role "%s".' % role_name,
  448. nodes.literal_block(self.block_text, self.block_text),
  449. line=self.lineno)
  450. return messages + [error]
  451. roles._roles[''] = role
  452. return messages
  453. class Title(Directive):
  454. required_arguments = 1
  455. optional_arguments = 0
  456. final_argument_whitespace = True
  457. def run(self):
  458. self.state_machine.document['title'] = self.arguments[0]
  459. return []
  460. class MetaBody(states.SpecializedBody):
  461. def field_marker(self, match, context, next_state):
  462. """Meta element."""
  463. node, blank_finish = self.parsemeta(match)
  464. self.parent += node
  465. return [], next_state, []
  466. def parsemeta(self, match):
  467. name = self.parse_field_marker(match)
  468. name = nodes.unescape(utils.escape2null(name))
  469. (indented, indent, line_offset, blank_finish
  470. ) = self.state_machine.get_first_known_indented(match.end())
  471. node = nodes.meta()
  472. node['content'] = nodes.unescape(utils.escape2null(
  473. ' '.join(indented)))
  474. if not indented:
  475. line = self.state_machine.line
  476. msg = self.reporter.info(
  477. 'No content for meta tag "%s".' % name,
  478. nodes.literal_block(line, line))
  479. return msg, blank_finish
  480. tokens = name.split()
  481. try:
  482. attname, val = utils.extract_name_value(tokens[0])[0]
  483. node[attname.lower()] = val
  484. except utils.NameValueError:
  485. node['name'] = tokens[0]
  486. for token in tokens[1:]:
  487. try:
  488. attname, val = utils.extract_name_value(token)[0]
  489. node[attname.lower()] = val
  490. except utils.NameValueError as detail:
  491. line = self.state_machine.line
  492. msg = self.reporter.error(
  493. 'Error parsing meta tag attribute "%s": %s.'
  494. % (token, detail), nodes.literal_block(line, line))
  495. return msg, blank_finish
  496. return node, blank_finish
  497. class Meta(Directive):
  498. has_content = True
  499. SMkwargs = {'state_classes': (MetaBody,)}
  500. def run(self):
  501. self.assert_has_content()
  502. node = nodes.Element()
  503. new_line_offset, blank_finish = self.state.nested_list_parse(
  504. self.content, self.content_offset, node,
  505. initial_state='MetaBody', blank_finish=True,
  506. state_machine_kwargs=self.SMkwargs)
  507. if (new_line_offset - self.content_offset) != len(self.content):
  508. # incomplete parse of block?
  509. error = self.reporter.error(
  510. 'Invalid meta directive.',
  511. nodes.literal_block(self.block_text, self.block_text),
  512. line=self.lineno)
  513. node += error
  514. # insert at begin of document
  515. index = self.state.document.first_child_not_matching_class(
  516. (nodes.Titular, nodes.meta)) or 0
  517. self.state.document[index:index] = node.children
  518. return []
  519. class Date(Directive):
  520. has_content = True
  521. def run(self):
  522. if not isinstance(self.state, states.SubstitutionDef):
  523. raise self.error(
  524. 'Invalid context: the "%s" directive can only be used within '
  525. 'a substitution definition.' % self.name)
  526. format_str = '\n'.join(self.content) or '%Y-%m-%d'
  527. # @@@
  528. # Use timestamp from the `SOURCE_DATE_EPOCH`_ environment variable?
  529. # Pro: Docutils-generated documentation
  530. # can easily be part of `reproducible software builds`__
  531. #
  532. # __ https://reproducible-builds.org/
  533. #
  534. # Con: Changes the specs, hard to predict behaviour,
  535. #
  536. # See also the discussion about \date \time \year in TeX
  537. # http://tug.org/pipermail/tex-k/2016-May/002704.html
  538. # source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')
  539. # if (source_date_epoch):
  540. # text = time.strftime(format_str,
  541. # time.gmtime(int(source_date_epoch)))
  542. # else:
  543. text = time.strftime(format_str)
  544. return [nodes.Text(text)]
  545. class TestDirective(Directive):
  546. """This directive is useful only for testing purposes."""
  547. optional_arguments = 1
  548. final_argument_whitespace = True
  549. option_spec = {'option': directives.unchanged_required}
  550. has_content = True
  551. def run(self):
  552. if self.content:
  553. text = '\n'.join(self.content)
  554. info = self.reporter.info(
  555. 'Directive processed. Type="%s", arguments=%r, options=%r, '
  556. 'content:' % (self.name, self.arguments, self.options),
  557. nodes.literal_block(text, text), line=self.lineno)
  558. else:
  559. info = self.reporter.info(
  560. 'Directive processed. Type="%s", arguments=%r, options=%r, '
  561. 'content: None' % (self.name, self.arguments, self.options),
  562. line=self.lineno)
  563. return [info]