"""Fenced code blocks are parsed as directives,
if the block starts with ``{directive_name}``,
followed by arguments on the same line.
Directive options are read from a YAML block,
if the first content line starts with ``---``, e.g.
::
```{directive_name} arguments
---
option1: name
option2: |
Longer text block
---
content...
```
Or the option block will be parsed if the first content line starts with ``:``,
as a YAML block consisting of every line that starts with a ``:``, e.g.
::
```{directive_name} arguments
:option1: name
:option2: other
content...
```
If the first line of a directive's content is blank, this will be stripped
from the content.
This is to allow for separation between the option block and content.
"""
from __future__ import annotations
import datetime
import re
from textwrap import dedent
from typing import Any, Callable
import yaml
from docutils.parsers.rst import Directive
from docutils.parsers.rst.directives.misc import TestDirective
[docs]class DirectiveParsingError(Exception):
"""Raise on parsing/validation error."""
pass
[docs]def parse_directive_text(
directive_class: type[Directive],
first_line: str,
content: str,
validate_options: bool = True,
) -> tuple[list[str], dict, list[str], int]:
"""Parse (and validate) the full directive text.
:param first_line: The text on the same line as the directive name.
May be an argument or body text, dependent on the directive
:param content: All text after the first line. Can include options.
:param validate_options: Whether to validate the values of options
:returns: (arguments, options, body_lines, content_offset)
"""
if directive_class.option_spec:
body, options = parse_directive_options(
content, directive_class, validate=validate_options
)
body_lines = body.splitlines()
content_offset = len(content.splitlines()) - len(body_lines)
else:
# If there are no possible options, we do not look for a YAML block
options = {}
body_lines = content.splitlines()
content_offset = 0
if not (directive_class.required_arguments or directive_class.optional_arguments):
# If there are no possible arguments, then the body starts on the argument line
if first_line:
body_lines.insert(0, first_line)
arguments = []
else:
arguments = parse_directive_arguments(directive_class, first_line)
# remove first line of body if blank
# this is to allow space between the options and the content
if body_lines and not body_lines[0].strip():
body_lines = body_lines[1:]
content_offset += 1
# check for body content
if body_lines and not directive_class.has_content:
raise DirectiveParsingError("No content permitted")
return arguments, options, body_lines, content_offset
[docs]def parse_directive_options(
content: str, directive_class: type[Directive], validate: bool = True
):
"""Parse (and validate) the directive option section."""
options: dict[str, Any] = {}
if content.startswith("---"):
content = "\n".join(content.splitlines()[1:])
match = re.search(r"^-{3,}", content, re.MULTILINE)
if match:
yaml_block = content[: match.start()]
content = content[match.end() + 1 :] # TODO advance line number
else:
yaml_block = content
content = ""
yaml_block = dedent(yaml_block)
try:
options = yaml.safe_load(yaml_block) or {}
except (yaml.parser.ParserError, yaml.scanner.ScannerError) as error:
raise DirectiveParsingError("Invalid options YAML: " + str(error))
elif content.lstrip().startswith(":"):
content_lines = content.splitlines() # type: list
yaml_lines = []
while content_lines:
if not content_lines[0].lstrip().startswith(":"):
break
yaml_lines.append(content_lines.pop(0).lstrip()[1:])
yaml_block = "\n".join(yaml_lines)
content = "\n".join(content_lines)
try:
options = yaml.safe_load(yaml_block) or {}
except (yaml.parser.ParserError, yaml.scanner.ScannerError) as error:
raise DirectiveParsingError("Invalid options YAML: " + str(error))
if not isinstance(options, dict):
raise DirectiveParsingError(f"Invalid options (not dict): {options}")
if (not validate) or issubclass(directive_class, TestDirective):
# technically this directive spec only accepts one option ('option')
# but since its for testing only we accept all options
return content, options
# check options against spec
options_spec: dict[str, Callable] = directive_class.option_spec
for name, value in list(options.items()):
try:
convertor = options_spec[name]
except KeyError:
raise DirectiveParsingError(f"Unknown option: {name}")
if not isinstance(value, str):
if value is True or value is None:
value = None # flag converter requires no argument
elif isinstance(value, (int, float, datetime.date, datetime.datetime)):
# convertor always requires string input
value = str(value)
else:
raise DirectiveParsingError(
f'option "{name}" value not string (enclose with ""): {value}'
)
try:
converted_value = convertor(value)
except (ValueError, TypeError) as error:
raise DirectiveParsingError(
"Invalid option value: (option: '{}'; value: {})\n{}".format(
name, value, error
)
)
options[name] = converted_value
return content, options
[docs]def parse_directive_arguments(directive, arg_text):
"""Parse (and validate) the directive argument section."""
required = directive.required_arguments
optional = directive.optional_arguments
arguments = arg_text.split()
if len(arguments) < required:
raise DirectiveParsingError(
f"{required} argument(s) required, {len(arguments)} supplied"
)
elif len(arguments) > required + optional:
if directive.final_argument_whitespace:
arguments = arg_text.split(None, required + optional - 1)
else:
raise DirectiveParsingError(
"maximum {} argument(s) allowed, {} supplied".format(
required + optional, len(arguments)
)
)
return arguments