Skip to content

Commit ee4c29d

Browse files
authored
👌 Change non-fatal directive parsing errors to warnings (#682)
For non-fatal errors, such as; faulty options syntax, unknown option keys, and invalid option values, a warning is raised, but the directive is still run (without the erroneous options). The warning is given the `myst.directive_parse` type, which can be suppressed.
1 parent aa3f04d commit ee4c29d

File tree

9 files changed

+207
-89
lines changed

9 files changed

+207
-89
lines changed

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
("py:class", "StringList"),
8080
("py:class", "DocutilsRenderer"),
8181
("py:class", "MockStateMachine"),
82+
("py:exc", "MarkupError"),
8283
]
8384

8485
# -- MyST settings ---------------------------------------------------

myst_parser/mdit_to_docutils/base.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
MockState,
5353
MockStateMachine,
5454
)
55-
from myst_parser.parsers.directives import DirectiveParsingError, parse_directive_text
55+
from myst_parser.parsers.directives import MarkupError, parse_directive_text
5656
from myst_parser.warnings_ import MystWarnings, create_warning
5757
from .html_to_nodes import html_to_nodes
5858
from .utils import is_external_url
@@ -1462,19 +1462,16 @@ def run_directive(
14621462
:param position: The line number of the first line
14631463
14641464
"""
1465-
# TODO directive name white/black lists
1466-
14671465
self.document.current_line = position
14681466

14691467
# get directive class
1470-
output: tuple[Directive, list] = directives.directive(
1468+
output: tuple[Directive | None, list] = directives.directive(
14711469
name, self.language_module_rst, self.document
14721470
)
14731471
directive_class, messages = output
14741472
if not directive_class:
14751473
error = self.reporter.error(
14761474
f'Unknown directive type "{name}".\n',
1477-
# nodes.literal_block(content, content),
14781475
line=position,
14791476
)
14801477
return [error] + messages
@@ -1486,26 +1483,32 @@ def run_directive(
14861483
directive_class.option_spec["relative-docs"] = directives.path
14871484

14881485
try:
1489-
arguments, options, body_lines, content_offset = parse_directive_text(
1490-
directive_class, first_line, content
1491-
)
1492-
except DirectiveParsingError as error:
1486+
parsed = parse_directive_text(directive_class, first_line, content)
1487+
except MarkupError as error:
14931488
error = self.reporter.error(
14941489
f"Directive '{name}': {error}",
1495-
nodes.literal_block(content, content),
14961490
line=position,
14971491
)
14981492
return [error]
14991493

1494+
if parsed.warnings:
1495+
_errors = ",\n".join(parsed.warnings)
1496+
self.create_warning(
1497+
f"{name!r}: {_errors}",
1498+
MystWarnings.DIRECTIVE_PARSING,
1499+
line=position,
1500+
append_to=self.current_node,
1501+
)
1502+
15001503
# initialise directive
15011504
if issubclass(directive_class, Include):
15021505
directive_instance = MockIncludeDirective(
15031506
self,
15041507
name=name,
15051508
klass=directive_class,
1506-
arguments=arguments,
1507-
options=options,
1508-
body=body_lines,
1509+
arguments=parsed.arguments,
1510+
options=parsed.options,
1511+
body=parsed.body,
15091512
lineno=position,
15101513
)
15111514
else:
@@ -1514,17 +1517,17 @@ def run_directive(
15141517
directive_instance = directive_class(
15151518
name=name,
15161519
# the list of positional arguments
1517-
arguments=arguments,
1520+
arguments=parsed.arguments,
15181521
# a dictionary mapping option names to values
1519-
options=options,
1522+
options=parsed.options,
15201523
# the directive content line by line
1521-
content=StringList(body_lines, self.document["source"]),
1524+
content=StringList(parsed.body, self.document["source"]),
15221525
# the absolute line number of the first line of the directive
15231526
lineno=position,
15241527
# the line offset of the first line of the content
1525-
content_offset=content_offset,
1528+
content_offset=parsed.body_offset,
15261529
# a string containing the entire directive
1527-
block_text="\n".join(body_lines),
1530+
block_text="\n".join(parsed.body),
15281531
state=state,
15291532
state_machine=state_machine,
15301533
)

myst_parser/mocking.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from docutils.statemachine import StringList
1818
from docutils.utils import unescape
1919

20-
from .parsers.directives import parse_directive_text
20+
from .parsers.directives import MarkupError, parse_directive_text
2121

2222
if TYPE_CHECKING:
2323
from .mdit_to_docutils.base import DocutilsRenderer
@@ -133,19 +133,21 @@ def parse_directive_block(
133133
) -> tuple[list, dict, StringList, int]:
134134
"""Parse the full directive text
135135
136+
:raises MarkupError: for errors in parsing the directive
136137
:returns: (arguments, options, content, content_offset)
137138
"""
139+
# note this is essentially only used by the docutils `role` directive
138140
if option_presets:
139141
raise MockingError("parse_directive_block: option_presets not implemented")
140142
# TODO should argument_str always be ""?
141-
arguments, options, body_lines, content_offset = parse_directive_text(
142-
directive, "", "\n".join(content)
143-
)
143+
parsed = parse_directive_text(directive, "", "\n".join(content))
144+
if parsed.warnings:
145+
raise MarkupError(",".join(parsed.warnings))
144146
return (
145-
arguments,
146-
options,
147-
StringList(body_lines, source=content.source),
148-
line_offset + content_offset,
147+
parsed.arguments,
148+
parsed.options,
149+
StringList(parsed.body, source=content.source),
150+
line_offset + parsed.body_offset,
149151
)
150152

151153
def nested_parse(

myst_parser/parsers/directives.py

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -37,39 +37,51 @@
3737

3838
import datetime
3939
import re
40+
from dataclasses import dataclass
4041
from textwrap import dedent
4142
from typing import Any, Callable
4243

4344
import yaml
4445
from docutils.parsers.rst import Directive
4546
from docutils.parsers.rst.directives.misc import TestDirective
47+
from docutils.parsers.rst.states import MarkupError
4648

4749

48-
class DirectiveParsingError(Exception):
49-
"""Raise on parsing/validation error."""
50-
51-
pass
50+
@dataclass
51+
class DirectiveParsingResult:
52+
arguments: list[str]
53+
"""The arguments parsed from the first line."""
54+
options: dict
55+
"""Options parsed from the YAML block."""
56+
body: list[str]
57+
"""The lines of body content"""
58+
body_offset: int
59+
"""The number of lines to the start of the body content."""
60+
warnings: list[str]
61+
"""List of non-fatal errors encountered during parsing."""
5262

5363

5464
def parse_directive_text(
5565
directive_class: type[Directive],
5666
first_line: str,
5767
content: str,
5868
validate_options: bool = True,
59-
) -> tuple[list[str], dict, list[str], int]:
69+
) -> DirectiveParsingResult:
6070
"""Parse (and validate) the full directive text.
6171
6272
:param first_line: The text on the same line as the directive name.
6373
May be an argument or body text, dependent on the directive
6474
:param content: All text after the first line. Can include options.
6575
:param validate_options: Whether to validate the values of options
6676
67-
:returns: (arguments, options, body_lines, content_offset)
77+
:raises MarkupError: if there is a fatal parsing/validation error
6878
"""
79+
parse_errors: list[str] = []
6980
if directive_class.option_spec:
70-
body, options = parse_directive_options(
81+
body, options, option_errors = parse_directive_options(
7182
content, directive_class, validate=validate_options
7283
)
84+
parse_errors.extend(option_errors)
7385
body_lines = body.splitlines()
7486
content_offset = len(content.splitlines()) - len(body_lines)
7587
else:
@@ -94,16 +106,22 @@ def parse_directive_text(
94106

95107
# check for body content
96108
if body_lines and not directive_class.has_content:
97-
raise DirectiveParsingError("No content permitted")
109+
parse_errors.append("Has content, but none permitted")
98110

99-
return arguments, options, body_lines, content_offset
111+
return DirectiveParsingResult(
112+
arguments, options, body_lines, content_offset, parse_errors
113+
)
100114

101115

102116
def parse_directive_options(
103117
content: str, directive_class: type[Directive], validate: bool = True
104-
):
105-
"""Parse (and validate) the directive option section."""
118+
) -> tuple[str, dict, list[str]]:
119+
"""Parse (and validate) the directive option section.
120+
121+
:returns: (content, options, validation_errors)
122+
"""
106123
options: dict[str, Any] = {}
124+
validation_errors: list[str] = []
107125
if content.startswith("---"):
108126
content = "\n".join(content.splitlines()[1:])
109127
match = re.search(r"^-{3,}", content, re.MULTILINE)
@@ -116,8 +134,8 @@ def parse_directive_options(
116134
yaml_block = dedent(yaml_block)
117135
try:
118136
options = yaml.safe_load(yaml_block) or {}
119-
except (yaml.parser.ParserError, yaml.scanner.ScannerError) as error:
120-
raise DirectiveParsingError("Invalid options YAML: " + str(error))
137+
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
138+
validation_errors.append("Invalid options format (bad YAML)")
121139
elif content.lstrip().startswith(":"):
122140
content_lines = content.splitlines() # type: list
123141
yaml_lines = []
@@ -129,62 +147,75 @@ def parse_directive_options(
129147
content = "\n".join(content_lines)
130148
try:
131149
options = yaml.safe_load(yaml_block) or {}
132-
except (yaml.parser.ParserError, yaml.scanner.ScannerError) as error:
133-
raise DirectiveParsingError("Invalid options YAML: " + str(error))
134-
if not isinstance(options, dict):
135-
raise DirectiveParsingError(f"Invalid options (not dict): {options}")
150+
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
151+
validation_errors.append("Invalid options format (bad YAML)")
152+
153+
if not isinstance(options, dict):
154+
options = {}
155+
validation_errors.append("Invalid options format (not a dict)")
156+
157+
if validation_errors:
158+
return content, options, validation_errors
136159

137160
if (not validate) or issubclass(directive_class, TestDirective):
138161
# technically this directive spec only accepts one option ('option')
139162
# but since its for testing only we accept all options
140-
return content, options
163+
return content, options, validation_errors
141164

142165
# check options against spec
143166
options_spec: dict[str, Callable] = directive_class.option_spec
144-
for name, value in list(options.items()):
167+
unknown_options: list[str] = []
168+
new_options: dict[str, Any] = {}
169+
for name, value in options.items():
145170
try:
146171
convertor = options_spec[name]
147172
except KeyError:
148-
raise DirectiveParsingError(f"Unknown option: {name}")
173+
unknown_options.append(name)
174+
continue
149175
if not isinstance(value, str):
150176
if value is True or value is None:
151177
value = None # flag converter requires no argument
152178
elif isinstance(value, (int, float, datetime.date, datetime.datetime)):
153179
# convertor always requires string input
154180
value = str(value)
155181
else:
156-
raise DirectiveParsingError(
182+
validation_errors.append(
157183
f'option "{name}" value not string (enclose with ""): {value}'
158184
)
185+
continue
159186
try:
160187
converted_value = convertor(value)
161188
except (ValueError, TypeError) as error:
162-
raise DirectiveParsingError(
163-
"Invalid option value: (option: '{}'; value: {})\n{}".format(
164-
name, value, error
165-
)
189+
validation_errors.append(
190+
f"Invalid option value for {name!r}: {value}: {error}"
166191
)
167-
options[name] = converted_value
192+
else:
193+
new_options[name] = converted_value
194+
195+
if unknown_options:
196+
validation_errors.append(
197+
f"Unknown option keys: {sorted(unknown_options)} "
198+
f"(allowed: {sorted(options_spec)})"
199+
)
168200

169-
return content, options
201+
return content, new_options, validation_errors
170202

171203

172-
def parse_directive_arguments(directive, arg_text):
204+
def parse_directive_arguments(
205+
directive_cls: type[Directive], arg_text: str
206+
) -> list[str]:
173207
"""Parse (and validate) the directive argument section."""
174-
required = directive.required_arguments
175-
optional = directive.optional_arguments
208+
required = directive_cls.required_arguments
209+
optional = directive_cls.optional_arguments
176210
arguments = arg_text.split()
177211
if len(arguments) < required:
178-
raise DirectiveParsingError(
179-
f"{required} argument(s) required, {len(arguments)} supplied"
180-
)
212+
raise MarkupError(f"{required} argument(s) required, {len(arguments)} supplied")
181213
elif len(arguments) > required + optional:
182-
if directive.final_argument_whitespace:
214+
if directive_cls.final_argument_whitespace:
183215
arguments = arg_text.split(None, required + optional - 1)
184216
else:
185-
raise DirectiveParsingError(
186-
"maximum {} argument(s) allowed, {} supplied".format(
187-
required + optional, len(arguments)
188-
)
217+
raise MarkupError(
218+
f"maximum {required + optional} argument(s) allowed, "
219+
f"{len(arguments)} supplied"
189220
)
190221
return arguments

myst_parser/warnings_.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ class MystWarnings(Enum):
2929
MD_HEADING_NESTED = "nested_header"
3030
"""Header found nested in another element."""
3131

32+
DIRECTIVE_PARSING = "directive_parse"
33+
"""Issue parsing directive."""
34+
3235
# cross-reference resolution
3336
XREF_AMBIGUOUS = "xref_ambiguous"
3437
"""Multiple targets were found for a cross-reference."""

tests/test_renderers/fixtures/directive_options.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,13 @@ foo
131131
```
132132
.
133133
<document source="<src>/index.md">
134-
<system_message level="3" line="1" source="<src>/index.md" type="ERROR">
134+
<system_message level="2" line="1" source="<src>/index.md" type="WARNING">
135+
<paragraph>
136+
'restructuredtext-test-directive': Invalid options format (bad YAML) [myst.directive_parse]
137+
<system_message level="1" line="1" source="<src>/index.md" type="INFO">
135138
<paragraph>
136-
Directive 'restructuredtext-test-directive': Invalid options YAML: mapping values are not allowed here
137-
in "<unicode string>", line 2, column 8:
138-
option2: b
139-
^
139+
Directive processed. Type="restructuredtext-test-directive", arguments=[], options={}, content:
140140
<literal_block xml:space="preserve">
141-
:option1
142-
:option2: b
143141
foo
144142
.
145143

0 commit comments

Comments
 (0)