1# $Id$
2# Author: David Goodger <goodger@python.org>
3# Copyright: This module has been placed in the public domain.
4
5"""
6This package contains directive implementation modules.
7"""
8
9from __future__ import annotations
10
11__docformat__ = 'reStructuredText'
12
13import re
14import codecs
15from importlib import import_module
16
17from docutils import nodes, parsers
18from docutils.utils import split_escaped_whitespace, escape2null
19from docutils.parsers.rst.languages import en as _fallback_language_module
20
21TYPE_CHECKING = False
22if TYPE_CHECKING:
23 from collections.abc import Callable, Sequence
24
25
26_directive_registry = {
27 'attention': ('admonitions', 'Attention'),
28 'caution': ('admonitions', 'Caution'),
29 'code': ('body', 'CodeBlock'),
30 'danger': ('admonitions', 'Danger'),
31 'error': ('admonitions', 'Error'),
32 'important': ('admonitions', 'Important'),
33 'note': ('admonitions', 'Note'),
34 'tip': ('admonitions', 'Tip'),
35 'hint': ('admonitions', 'Hint'),
36 'warning': ('admonitions', 'Warning'),
37 'admonition': ('admonitions', 'Admonition'),
38 'sidebar': ('body', 'Sidebar'),
39 'topic': ('body', 'Topic'),
40 'line-block': ('body', 'LineBlock'),
41 'parsed-literal': ('body', 'ParsedLiteral'),
42 'math': ('body', 'MathBlock'),
43 'rubric': ('body', 'Rubric'),
44 'epigraph': ('body', 'Epigraph'),
45 'highlights': ('body', 'Highlights'),
46 'pull-quote': ('body', 'PullQuote'),
47 'compound': ('body', 'Compound'),
48 'container': ('body', 'Container'),
49 # 'questions': ('body', 'question_list'),
50 'table': ('tables', 'RSTTable'),
51 'csv-table': ('tables', 'CSVTable'),
52 'list-table': ('tables', 'ListTable'),
53 'image': ('images', 'Image'),
54 'figure': ('images', 'Figure'),
55 'contents': ('parts', 'Contents'),
56 'sectnum': ('parts', 'Sectnum'),
57 'header': ('parts', 'Header'),
58 'footer': ('parts', 'Footer'),
59 # 'footnotes': ('parts', 'footnotes'),
60 # 'citations': ('parts', 'citations'),
61 'target-notes': ('references', 'TargetNotes'),
62 'meta': ('misc', 'Meta'),
63 # 'imagemap': ('html', 'imagemap'),
64 'raw': ('misc', 'Raw'),
65 'include': ('misc', 'Include'),
66 'replace': ('misc', 'Replace'),
67 'unicode': ('misc', 'Unicode'),
68 'class': ('misc', 'Class'),
69 'role': ('misc', 'Role'),
70 'default-role': ('misc', 'DefaultRole'),
71 'title': ('misc', 'Title'),
72 'date': ('misc', 'Date'),
73 'restructuredtext-test-directive': ('misc', 'TestDirective'),
74 }
75"""Mapping of directive name to (module name, class name). The
76directive name is canonical & must be lowercase. Language-dependent
77names are defined in the ``language`` subpackage."""
78
79_directives = {}
80"""Cache of imported directives."""
81
82
83def directive(directive_name, language_module, document):
84 """
85 Locate and return a directive function from its language-dependent name.
86 If not found in the current language, check English. Return None if the
87 named directive cannot be found.
88 """
89 normname = directive_name.lower()
90 messages = []
91 msg_text = []
92 if normname in _directives:
93 return _directives[normname], messages
94 canonicalname = None
95 try:
96 canonicalname = language_module.directives[normname]
97 except AttributeError as error:
98 msg_text.append('Problem retrieving directive entry from language '
99 'module %r: %s.' % (language_module, error))
100 except KeyError:
101 msg_text.append('No directive entry for "%s" in module "%s".'
102 % (directive_name, language_module.__name__))
103 if not canonicalname:
104 try:
105 canonicalname = _fallback_language_module.directives[normname]
106 msg_text.append('Using English fallback for directive "%s".'
107 % directive_name)
108 except KeyError:
109 msg_text.append('Trying "%s" as canonical directive name.'
110 % directive_name)
111 # The canonical name should be an English name, but just in case:
112 canonicalname = normname
113 if msg_text:
114 message = document.reporter.info(
115 '\n'.join(msg_text), line=document.current_line)
116 messages.append(message)
117 try:
118 modulename, classname = _directive_registry[canonicalname]
119 except KeyError:
120 # Error handling done by caller.
121 return None, messages
122 try:
123 module = import_module('docutils.parsers.rst.directives.'+modulename)
124 except ImportError as detail:
125 messages.append(document.reporter.error(
126 'Error importing directive module "%s" (directive "%s"):\n%s'
127 % (modulename, directive_name, detail),
128 line=document.current_line))
129 return None, messages
130 try:
131 directive = getattr(module, classname)
132 _directives[normname] = directive
133 except AttributeError:
134 messages.append(document.reporter.error(
135 'No directive class "%s" in module "%s" (directive "%s").'
136 % (classname, modulename, directive_name),
137 line=document.current_line))
138 return None, messages
139 return directive, messages
140
141
142def register_directive(name, directive) -> None:
143 """
144 Register a nonstandard application-defined directive function.
145 Language lookups are not needed for such functions.
146 """
147 _directives[name] = directive
148
149
150# conversion functions for `Directive.option_spec`
151# ------------------------------------------------
152#
153# see also `parsers.rst.Directive` in ../__init__.py.
154
155
156def flag(argument: str) -> None:
157 """
158 Check for a valid flag option (no argument) and return ``None``.
159 (Directive option conversion function.)
160
161 Raise ``ValueError`` if an argument is found.
162 """
163 if argument and argument.strip():
164 raise ValueError('no argument is allowed; "%s" supplied' % argument)
165 else:
166 return None
167
168
169def unchanged_required(argument: str) -> str:
170 """
171 Return the argument text, unchanged.
172
173 Directive option conversion function for options that require a value.
174
175 Raise ``ValueError`` if no argument is found.
176 """
177 if argument is None:
178 raise ValueError('argument required but none supplied')
179 else:
180 return argument # unchanged!
181
182
183def unchanged(argument: str) -> str:
184 """
185 Return the argument text, unchanged.
186 (Directive option conversion function.)
187
188 No argument implies empty string ("").
189 """
190 if argument is None:
191 return ''
192 else:
193 return argument # unchanged!
194
195
196def path(argument: str) -> str:
197 """
198 Return the path argument unwrapped (with newlines removed).
199 (Directive option conversion function.)
200
201 Raise ``ValueError`` if no argument is found.
202 """
203 if argument is None:
204 raise ValueError('argument required but none supplied')
205 else:
206 return ''.join(s.strip() for s in argument.splitlines())
207
208
209def uri(argument: str) -> str:
210 """
211 Return the URI argument with unescaped whitespace removed.
212 (Directive option conversion function.)
213
214 Raise ``ValueError`` if no argument is found.
215 """
216 if argument is None:
217 raise ValueError('argument required but none supplied')
218 else:
219 parts = split_escaped_whitespace(escape2null(argument))
220 return ' '.join(''.join(nodes.unescape(part).split())
221 for part in parts)
222
223
224def nonnegative_int(argument: str) -> int:
225 """
226 Check for a nonnegative integer argument; raise ``ValueError`` if not.
227 (Directive option conversion function.)
228 """
229 value = int(argument)
230 if value < 0:
231 raise ValueError('negative value; must be positive or zero')
232 return value
233
234
235def percentage(argument: str) -> int:
236 """
237 Check for an integer percentage value with optional percent sign.
238 (Directive option conversion function.)
239 """
240 try:
241 argument = argument.rstrip(' %')
242 except AttributeError:
243 pass
244 return nonnegative_int(argument)
245
246
247CSS3_LENGTH_UNITS = ('em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax',
248 'cm', 'mm', 'Q', 'in', 'pt', 'pc', 'px')
249"""Length units that are supported by the reStructuredText parser.
250
251Corresponds to the `length units in CSS3`__.
252
253__ https://www.w3.org/TR/css-values-3/#lengths
254"""
255
256length_units = [*CSS3_LENGTH_UNITS]
257"""Deprecated, will be removed in Docutils 0.24 or equivalent."""
258
259
260def get_measure(argument, units):
261 """
262 Check for a positive argument of one of the `units`.
263
264 Return a normalized string of the form "<value><unit>"
265 (without space inbetween).
266
267 To be called from directive option conversion functions.
268 """
269 value, unit = nodes.parse_measure(argument)
270 if value < 0 or unit not in units:
271 raise ValueError(
272 'not a positive number or measure of one of the following units:\n'
273 + ', '.join(u for u in units if u))
274 return f'{value}{unit}'
275
276
277def length_or_unitless(argument: str) -> str:
278 return get_measure(argument, CSS3_LENGTH_UNITS + ('',))
279
280
281def length_or_percentage_or_unitless(argument, default=''):
282 """
283 Return normalized string of a length or percentage unit.
284 (Directive option conversion function.)
285
286 Add <default> if there is no unit. Raise ValueError if the argument is not
287 a positive measure of one of the valid CSS units (or without unit).
288
289 >>> length_or_percentage_or_unitless('3 pt')
290 '3pt'
291 >>> length_or_percentage_or_unitless('3%', 'em')
292 '3%'
293 >>> length_or_percentage_or_unitless('3')
294 '3'
295 >>> length_or_percentage_or_unitless('3', 'px')
296 '3px'
297 """
298 try:
299 return get_measure(argument, CSS3_LENGTH_UNITS + ('%',))
300 except ValueError as error:
301 try:
302 return get_measure(argument, ['']) + default
303 except ValueError:
304 raise error
305
306
307def class_option(argument: str) -> list[str]:
308 """
309 Convert the argument into a list of ID-compatible strings and return it.
310 (Directive option conversion function.)
311
312 Raise ``ValueError`` if no argument is found.
313 """
314 if argument is None:
315 raise ValueError('argument required but none supplied')
316 names = argument.split()
317 class_names = []
318 for name in names:
319 class_name = nodes.make_id(name)
320 if not class_name:
321 raise ValueError('cannot make "%s" into a class name' % name)
322 class_names.append(class_name)
323 return class_names
324
325
326unicode_pattern = re.compile(
327 r'(?:0x|x|\\x|U\+?|\\u)([0-9a-f]+)$|&#x([0-9a-f]+);$', re.IGNORECASE)
328
329
330def unicode_code(code):
331 r"""
332 Convert a Unicode character code to a Unicode character.
333 (Directive option conversion function.)
334
335 Codes may be decimal numbers, hexadecimal numbers (prefixed by ``0x``,
336 ``x``, ``\x``, ``U+``, ``u``, or ``\u``; e.g. ``U+262E``), or XML-style
337 numeric character entities (e.g. ``☮``). Other text remains as-is.
338
339 Raise ValueError for illegal Unicode code values.
340 """
341 try:
342 if code.isdigit(): # decimal number
343 return chr(int(code))
344 else:
345 match = unicode_pattern.match(code)
346 if match: # hex number
347 value = match.group(1) or match.group(2)
348 return chr(int(value, 16))
349 else: # other text
350 return code
351 except OverflowError as detail:
352 raise ValueError('code too large (%s)' % detail)
353
354
355def single_char_or_unicode(argument: str) -> str:
356 """
357 A single character is returned as-is. Unicode character codes are
358 converted as in `unicode_code`. (Directive option conversion function.)
359 """
360 char = unicode_code(argument)
361 if len(char) > 1:
362 raise ValueError('%r invalid; must be a single character or '
363 'a Unicode code' % char)
364 return char
365
366
367def single_char_or_whitespace_or_unicode(argument: str) -> str:
368 """
369 As with `single_char_or_unicode`, but "tab" and "space" are also supported.
370 (Directive option conversion function.)
371 """
372 if argument == 'tab':
373 char = '\t'
374 elif argument == 'space':
375 char = ' '
376 else:
377 char = single_char_or_unicode(argument)
378 return char
379
380
381def positive_int(argument: str) -> int:
382 """
383 Converts the argument into an integer. Raises ValueError for negative,
384 zero, or non-integer values. (Directive option conversion function.)
385 """
386 value = int(argument)
387 if value < 1:
388 raise ValueError('negative or zero value; must be positive')
389 return value
390
391
392def positive_int_list(argument: str) -> list[int]:
393 """
394 Converts a space- or comma-separated list of values into a Python list
395 of integers.
396 (Directive option conversion function.)
397
398 Raises ValueError for non-positive-integer values.
399 """
400 if ',' in argument:
401 entries = argument.split(',')
402 else:
403 entries = argument.split()
404 return [positive_int(entry) for entry in entries]
405
406
407def encoding(argument: str) -> str:
408 """
409 Verifies the encoding argument by lookup.
410 (Directive option conversion function.)
411
412 Raises ValueError for unknown encodings.
413 """
414 try:
415 codecs.lookup(argument)
416 except LookupError:
417 raise ValueError('unknown encoding: "%s"' % argument)
418 return argument
419
420
421def choice(argument, values):
422 """
423 Directive option utility function, supplied to enable options whose
424 argument must be a member of a finite set of possible values (must be
425 lower case). A custom conversion function must be written to use it. For
426 example::
427
428 from docutils.parsers.rst import directives
429
430 def yesno(argument: str):
431 return directives.choice(argument, ('yes', 'no'))
432
433 Raise ``ValueError`` if no argument is found or if the argument's value is
434 not valid (not an entry in the supplied list).
435 """
436 try:
437 value = argument.lower().strip()
438 except AttributeError:
439 raise ValueError('must supply an argument; choose from %s'
440 % format_values(values))
441 if value in values:
442 return value
443 else:
444 raise ValueError('"%s" unknown; choose from %s'
445 % (argument, format_values(values)))
446
447
448def format_values(values) -> str:
449 return '%s, or "%s"' % (', '.join('"%s"' % s for s in values[:-1]),
450 values[-1])
451
452
453def value_or(values: Sequence[str], other: type) -> Callable:
454 """
455 Directive option conversion function.
456
457 The argument can be any of `values` or `argument_type`.
458 """
459 def auto_or_other(argument: str):
460 if argument in values:
461 return argument
462 else:
463 return other(argument)
464 return auto_or_other
465
466
467def parser_name(argument: str) -> type[parsers.Parser]:
468 """
469 Return a docutils parser whose name matches the argument.
470 (Directive option conversion function.)
471
472 Return `None`, if the argument evaluates to `False`.
473 Raise `ValueError` if importing the parser module fails.
474 """
475 if not argument:
476 return None
477 try:
478 return parsers.get_parser_class(argument)
479 except ImportError as err:
480 raise ValueError(str(err))