Repository: executablebooks/MyST-Parser
Pull request #682 (2023-01-12 00:20:06)

👌 Change non-fatal directive parsing errors to warnings
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.

codecov[bot]: # [Codecov](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks) Report
Base: **89.66**% // Head: **89.99**% // Increases project coverage by **`+0.33%`** :tada:
> Coverage data is based on head [(`4353d05`)](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks) compared to base [(`505ac77`)](https://codecov.io/gh/executablebooks/MyST-Parser/commit/505ac7730b44a7080c6360976c88fce6d162292d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks).
> Patch coverage: 94.64% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #682      +/-   ##
==========================================
+ Coverage   89.66%   89.99%   +0.33%     
==========================================
  Files          23       23              
  Lines        2573     2599      +26     
==========================================
+ Hits         2307     2339      +32     
+ Misses        266      260       -6     
```

| Flag | Coverage Δ | |
|---|---|---|
| pytests | `89.99% <94.64%> (+0.33%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks) | Coverage Δ | |
|---|---|---|
| [myst\_parser/mocking.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvbW9ja2luZy5weQ==) | `86.77% <75.00%> (-0.29%)` | :arrow_down: |
| [myst\_parser/parsers/directives.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvcGFyc2Vycy9kaXJlY3RpdmVzLnB5) | `96.36% <95.45%> (+8.58%)` | :arrow_up: |
| [myst\_parser/mdit\_to\_docutils/base.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvbWRpdF90b19kb2N1dGlscy9iYXNlLnB5) | `92.84% <100.00%> (+0.02%)` | :arrow_up: |
| [myst\_parser/warnings\_.py](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks#diff-bXlzdF9wYXJzZXIvd2FybmluZ3NfLnB5) | `96.00% <100.00%> (+0.08%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/executablebooks/MyST-Parser/pull/682?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=executablebooks).

rowanc1: Very much want to see this in the JS side as well, it is a bit too strict.
rowanc1: I only looked at the tests, but this seems like a great addition to me 👍. I will follow suit in the JS side soon!
chrisjsewell: > Very much want to see this in the JS side as well, it is a bit too strict.

yep exactly, I know we talked about this

Either in this PR or a follow, was going to add changing unknown directives and roles to a warning, with a certain warning type
rowanc1: Awesome, can you tag me so I can make the JS one the same!

Patch:
diff --git a/docs/conf.py b/docs/conf.py
index ce88c57..f3ca339 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -79,6 +79,7 @@ nitpick_ignore = [
     ("py:class", "StringList"),
     ("py:class", "DocutilsRenderer"),
     ("py:class", "MockStateMachine"),
+    ("py:exc", "MarkupError"),
 ]
 
 # -- MyST settings ---------------------------------------------------
diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py
index 9ad1838..3f5f3cc 100644
--- a/myst_parser/mdit_to_docutils/base.py
+++ b/myst_parser/mdit_to_docutils/base.py
@@ -52,7 +52,7 @@ from myst_parser.mocking import (
     MockState,
     MockStateMachine,
 )
-from myst_parser.parsers.directives import DirectiveParsingError, parse_directive_text
+from myst_parser.parsers.directives import MarkupError, parse_directive_text
 from myst_parser.warnings_ import MystWarnings, create_warning
 from .html_to_nodes import html_to_nodes
 from .utils import is_external_url
@@ -1462,19 +1462,16 @@ class DocutilsRenderer(RendererProtocol):
         :param position: The line number of the first line
 
         """
-        # TODO directive name white/black lists
-
         self.document.current_line = position
 
         # get directive class
-        output: tuple[Directive, list] = directives.directive(
+        output: tuple[Directive | None, list] = directives.directive(
             name, self.language_module_rst, self.document
         )
         directive_class, messages = output
         if not directive_class:
             error = self.reporter.error(
                 f'Unknown directive type "{name}".\n',
-                # nodes.literal_block(content, content),
                 line=position,
             )
             return [error] + messages
@@ -1486,26 +1483,32 @@ class DocutilsRenderer(RendererProtocol):
             directive_class.option_spec["relative-docs"] = directives.path
 
         try:
-            arguments, options, body_lines, content_offset = parse_directive_text(
-                directive_class, first_line, content
-            )
-        except DirectiveParsingError as error:
+            parsed = parse_directive_text(directive_class, first_line, content)
+        except MarkupError as error:
             error = self.reporter.error(
                 f"Directive '{name}': {error}",
-                nodes.literal_block(content, content),
                 line=position,
             )
             return [error]
 
+        if parsed.warnings:
+            _errors = ",\n".join(parsed.warnings)
+            self.create_warning(
+                f"{name!r}: {_errors}",
+                MystWarnings.DIRECTIVE_PARSING,
+                line=position,
+                append_to=self.current_node,
+            )
+
         # initialise directive
         if issubclass(directive_class, Include):
             directive_instance = MockIncludeDirective(
                 self,
                 name=name,
                 klass=directive_class,
-                arguments=arguments,
-                options=options,
-                body=body_lines,
+                arguments=parsed.arguments,
+                options=parsed.options,
+                body=parsed.body,
                 lineno=position,
             )
         else:
@@ -1514,17 +1517,17 @@ class DocutilsRenderer(RendererProtocol):
             directive_instance = directive_class(
                 name=name,
                 # the list of positional arguments
-                arguments=arguments,
+                arguments=parsed.arguments,
                 # a dictionary mapping option names to values
-                options=options,
+                options=parsed.options,
                 # the directive content line by line
-                content=StringList(body_lines, self.document["source"]),
+                content=StringList(parsed.body, self.document["source"]),
                 # the absolute line number of the first line of the directive
                 lineno=position,
                 # the line offset of the first line of the content
-                content_offset=content_offset,
+                content_offset=parsed.body_offset,
                 # a string containing the entire directive
-                block_text="\n".join(body_lines),
+                block_text="\n".join(parsed.body),
                 state=state,
                 state_machine=state_machine,
             )
diff --git a/myst_parser/mocking.py b/myst_parser/mocking.py
index 6ae0cc0..1e8a769 100644
--- a/myst_parser/mocking.py
+++ b/myst_parser/mocking.py
@@ -17,7 +17,7 @@ from docutils.parsers.rst.states import Body, Inliner, RSTStateMachine
 from docutils.statemachine import StringList
 from docutils.utils import unescape
 
-from .parsers.directives import parse_directive_text
+from .parsers.directives import MarkupError, parse_directive_text
 
 if TYPE_CHECKING:
     from .mdit_to_docutils.base import DocutilsRenderer
@@ -133,19 +133,21 @@ class MockState:
     ) -> tuple[list, dict, StringList, int]:
         """Parse the full directive text
 
+        :raises MarkupError: for errors in parsing the directive
         :returns: (arguments, options, content, content_offset)
         """
+        # note this is essentially only used by the docutils `role` directive
         if option_presets:
             raise MockingError("parse_directive_block: option_presets not implemented")
         # TODO should argument_str always be ""?
-        arguments, options, body_lines, content_offset = parse_directive_text(
-            directive, "", "\n".join(content)
-        )
+        parsed = parse_directive_text(directive, "", "\n".join(content))
+        if parsed.warnings:
+            raise MarkupError(",".join(parsed.warnings))
         return (
-            arguments,
-            options,
-            StringList(body_lines, source=content.source),
-            line_offset + content_offset,
+            parsed.arguments,
+            parsed.options,
+            StringList(parsed.body, source=content.source),
+            line_offset + parsed.body_offset,
         )
 
     def nested_parse(
diff --git a/myst_parser/parsers/directives.py b/myst_parser/parsers/directives.py
index 5637254..efe001b 100644
--- a/myst_parser/parsers/directives.py
+++ b/myst_parser/parsers/directives.py
@@ -37,18 +37,28 @@ from __future__ import annotations
 
 import datetime
 import re
+from dataclasses import dataclass
 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
+from docutils.parsers.rst.states import MarkupError
 
 
-class DirectiveParsingError(Exception):
-    """Raise on parsing/validation error."""
-
-    pass
+@dataclass
+class DirectiveParsingResult:
+    arguments: list[str]
+    """The arguments parsed from the first line."""
+    options: dict
+    """Options parsed from the YAML block."""
+    body: list[str]
+    """The lines of body content"""
+    body_offset: int
+    """The number of lines to the start of the body content."""
+    warnings: list[str]
+    """List of non-fatal errors encountered during parsing."""
 
 
 def parse_directive_text(
@@ -56,7 +66,7 @@ def parse_directive_text(
     first_line: str,
     content: str,
     validate_options: bool = True,
-) -> tuple[list[str], dict, list[str], int]:
+) -> DirectiveParsingResult:
     """Parse (and validate) the full directive text.
 
     :param first_line: The text on the same line as the directive name.
@@ -64,12 +74,14 @@ def parse_directive_text(
     :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)
+    :raises MarkupError: if there is a fatal parsing/validation error
     """
+    parse_errors: list[str] = []
     if directive_class.option_spec:
-        body, options = parse_directive_options(
+        body, options, option_errors = parse_directive_options(
             content, directive_class, validate=validate_options
         )
+        parse_errors.extend(option_errors)
         body_lines = body.splitlines()
         content_offset = len(content.splitlines()) - len(body_lines)
     else:
@@ -94,16 +106,22 @@ def parse_directive_text(
 
     # check for body content
     if body_lines and not directive_class.has_content:
-        raise DirectiveParsingError("No content permitted")
+        parse_errors.append("Has content, but none permitted")
 
-    return arguments, options, body_lines, content_offset
+    return DirectiveParsingResult(
+        arguments, options, body_lines, content_offset, parse_errors
+    )
 
 
 def parse_directive_options(
     content: str, directive_class: type[Directive], validate: bool = True
-):
-    """Parse (and validate) the directive option section."""
+) -> tuple[str, dict, list[str]]:
+    """Parse (and validate) the directive option section.
+
+    :returns: (content, options, validation_errors)
+    """
     options: dict[str, Any] = {}
+    validation_errors: list[str] = []
     if content.startswith("---"):
         content = "\n".join(content.splitlines()[1:])
         match = re.search(r"^-{3,}", content, re.MULTILINE)
@@ -116,8 +134,8 @@ def parse_directive_options(
         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))
+        except (yaml.parser.ParserError, yaml.scanner.ScannerError):
+            validation_errors.append("Invalid options format (bad YAML)")
     elif content.lstrip().startswith(":"):
         content_lines = content.splitlines()  # type: list
         yaml_lines = []
@@ -129,23 +147,31 @@ def parse_directive_options(
         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}")
+        except (yaml.parser.ParserError, yaml.scanner.ScannerError):
+            validation_errors.append("Invalid options format (bad YAML)")
+
+    if not isinstance(options, dict):
+        options = {}
+        validation_errors.append("Invalid options format (not a dict)")
+
+    if validation_errors:
+        return content, options, validation_errors
 
     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
+        return content, options, validation_errors
 
     # check options against spec
     options_spec: dict[str, Callable] = directive_class.option_spec
-    for name, value in list(options.items()):
+    unknown_options: list[str] = []
+    new_options: dict[str, Any] = {}
+    for name, value in options.items():
         try:
             convertor = options_spec[name]
         except KeyError:
-            raise DirectiveParsingError(f"Unknown option: {name}")
+            unknown_options.append(name)
+            continue
         if not isinstance(value, str):
             if value is True or value is None:
                 value = None  # flag converter requires no argument
@@ -153,38 +179,43 @@ def parse_directive_options(
                 # convertor always requires string input
                 value = str(value)
             else:
-                raise DirectiveParsingError(
+                validation_errors.append(
                     f'option "{name}" value not string (enclose with ""): {value}'
                 )
+                continue
         try:
             converted_value = convertor(value)
         except (ValueError, TypeError) as error:
-            raise DirectiveParsingError(
-                "Invalid option value: (option: '{}'; value: {})\n{}".format(
-                    name, value, error
-                )
+            validation_errors.append(
+                f"Invalid option value for {name!r}: {value}: {error}"
             )
-        options[name] = converted_value
+        else:
+            new_options[name] = converted_value
+
+    if unknown_options:
+        validation_errors.append(
+            f"Unknown option keys: {sorted(unknown_options)} "
+            f"(allowed: {sorted(options_spec)})"
+        )
 
-    return content, options
+    return content, new_options, validation_errors
 
 
-def parse_directive_arguments(directive, arg_text):
+def parse_directive_arguments(
+    directive_cls: type[Directive], arg_text: str
+) -> list[str]:
     """Parse (and validate) the directive argument section."""
-    required = directive.required_arguments
-    optional = directive.optional_arguments
+    required = directive_cls.required_arguments
+    optional = directive_cls.optional_arguments
     arguments = arg_text.split()
     if len(arguments) < required:
-        raise DirectiveParsingError(
-            f"{required} argument(s) required, {len(arguments)} supplied"
-        )
+        raise MarkupError(f"{required} argument(s) required, {len(arguments)} supplied")
     elif len(arguments) > required + optional:
-        if directive.final_argument_whitespace:
+        if directive_cls.final_argument_whitespace:
             arguments = arg_text.split(None, required + optional - 1)
         else:
-            raise DirectiveParsingError(
-                "maximum {} argument(s) allowed, {} supplied".format(
-                    required + optional, len(arguments)
-                )
+            raise MarkupError(
+                f"maximum {required + optional} argument(s) allowed, "
+                f"{len(arguments)} supplied"
             )
     return arguments
diff --git a/myst_parser/warnings_.py b/myst_parser/warnings_.py
index 85d18e2..7756573 100644
--- a/myst_parser/warnings_.py
+++ b/myst_parser/warnings_.py
@@ -29,6 +29,9 @@ class MystWarnings(Enum):
     MD_HEADING_NESTED = "nested_header"
     """Header found nested in another element."""
 
+    DIRECTIVE_PARSING = "directive_parse"
+    """Issue parsing directive."""
+
     # cross-reference resolution
     XREF_AMBIGUOUS = "xref_ambiguous"
     """Multiple targets were found for a cross-reference."""


Test patch:
diff --git a/tests/test_renderers/fixtures/directive_options.md b/tests/test_renderers/fixtures/directive_options.md
index b9ae64a..76f9255 100644
--- a/tests/test_renderers/fixtures/directive_options.md
+++ b/tests/test_renderers/fixtures/directive_options.md
@@ -131,15 +131,13 @@ foo
 ```
 .
 <document source="<src>/index.md">
-    <system_message level="3" line="1" source="<src>/index.md" type="ERROR">
+    <system_message level="2" line="1" source="<src>/index.md" type="WARNING">
+        <paragraph>
+            'restructuredtext-test-directive': Invalid options format (bad YAML) [myst.directive_parse]
+    <system_message level="1" line="1" source="<src>/index.md" type="INFO">
         <paragraph>
-            Directive 'restructuredtext-test-directive': Invalid options YAML: mapping values are not allowed here
-              in "<unicode string>", line 2, column 8:
-                option2: b
-                       ^
+            Directive processed. Type="restructuredtext-test-directive", arguments=[], options={}, content:
         <literal_block xml:space="preserve">
-            :option1
-            :option2: b
             foo
 .
 
diff --git a/tests/test_renderers/fixtures/directive_parsing.txt b/tests/test_renderers/fixtures/directive_parsing.txt
index 35878bc..e1c76c0 100644
--- a/tests/test_renderers/fixtures/directive_parsing.txt
+++ b/tests/test_renderers/fixtures/directive_parsing.txt
@@ -8,6 +8,7 @@ body:
 - a
 content_offset: 0
 options: {}
+warnings: []
 .
 
 note: content in body only
@@ -21,6 +22,7 @@ body:
 - a
 content_offset: 0
 options: {}
+warnings: []
 .
 
 note: content after option
@@ -37,6 +39,7 @@ content_offset: 1
 options:
   class:
   - name
+warnings: []
 .
 
 note: content after option with new line
@@ -54,6 +57,7 @@ content_offset: 2
 options:
   class:
   - name
+warnings: []
 .
 
 note: content after yaml option
@@ -72,6 +76,7 @@ content_offset: 3
 options:
   class:
   - name
+warnings: []
 .
 
 note: content in first line and body
@@ -91,6 +96,7 @@ content_offset: 1
 options:
   class:
   - tip
+warnings: []
 .
 
 admonition: no options, no new line
@@ -105,6 +111,7 @@ body:
 - body line
 content_offset: 0
 options: {}
+warnings: []
 .
 
 admonition: no options, new line
@@ -120,6 +127,7 @@ body:
 - body line
 content_offset: 1
 options: {}
+warnings: []
 .
 
 admonition: with options
@@ -138,4 +146,73 @@ content_offset: 2
 options:
   class:
   - tip
+warnings: []
+.
+
+warning: bad yaml
+.
+```{note}
+---
+a: {
+---
+```
+.
+arguments: []
+body: []
+content_offset: 3
+options: {}
+warnings:
+- Invalid options format (bad YAML)
+.
+
+warning: yaml not a dict
+.
+```{note}
+---
+a
+---
+```
+.
+arguments: []
+body: []
+content_offset: 3
+options: {}
+warnings:
+- Invalid options format (not a dict)
+.
+
+warning: unknown option name
+.
+```{note}
+:unknown: name
+```
+.
+arguments: []
+body: []
+content_offset: 1
+options: {}
+warnings:
+- 'Unknown option keys: [''unknown''] (allowed: [''class'', ''name''])'
+.
+
+warning: invalid option value
+.
+```{note}
+:class: 1
+```
+.
+arguments: []
+body: []
+content_offset: 1
+options: {}
+warnings:
+- 'Invalid option value for ''class'': 1: cannot make "1" into a class name'
+.
+
+error: missing argument
+.
+```{admonition}
+```
+.
+error: 1 argument(s) required, 0 supplied
 .
diff --git a/tests/test_renderers/fixtures/reporter_warnings.md b/tests/test_renderers/fixtures/reporter_warnings.md
index 59a8923..7c3cc78 100644
--- a/tests/test_renderers/fixtures/reporter_warnings.md
+++ b/tests/test_renderers/fixtures/reporter_warnings.md
@@ -150,10 +150,8 @@ bad-option-value
 :class: [1]
 ```
 .
-<string>:1: (ERROR/3) Directive 'note': option "class" value not string (enclose with ""): [1]
-
-:class: [1]
-
+<string>:1: (WARNING/2) 'note': option "class" value not string (enclose with ""): [1] [myst.directive_parse]
+<string>:1: (ERROR/3) Content block expected for the "note" directive; none found.
 .
 
 header nested in admonition
diff --git a/tests/test_renderers/test_parse_directives.py b/tests/test_renderers/test_parse_directives.py
index ae6792b..87f33f6 100644
--- a/tests/test_renderers/test_parse_directives.py
+++ b/tests/test_renderers/test_parse_directives.py
@@ -6,7 +6,7 @@ from docutils.parsers.rst.directives.admonitions import Admonition, Note
 from docutils.parsers.rst.directives.body import Rubric
 from markdown_it import MarkdownIt
 
-from myst_parser.parsers.directives import DirectiveParsingError, parse_directive_text
+from myst_parser.parsers.directives import MarkupError, parse_directive_text
 
 FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures")
 
@@ -23,18 +23,23 @@ def test_parsing(file_params):
         klass = Admonition
     else:
         raise AssertionError(f"Unknown directive: {name}")
-    arguments, options, body_lines, content_offset = parse_directive_text(
-        klass, first_line[0] if first_line else "", tokens[0].content
-    )
-    outcome = yaml.safe_dump(
-        {
-            "arguments": arguments,
-            "options": options,
-            "body": body_lines,
-            "content_offset": content_offset,
-        },
-        sort_keys=True,
-    )
+    try:
+        result = parse_directive_text(
+            klass, first_line[0] if first_line else "", tokens[0].content
+        )
+    except MarkupError as err:
+        outcome = f"error: {err}"
+    else:
+        outcome = yaml.safe_dump(
+            {
+                "arguments": result.arguments,
+                "options": result.options,
+                "body": result.body,
+                "content_offset": result.body_offset,
+                "warnings": result.warnings,
+            },
+            sort_keys=True,
+        )
     file_params.assert_expected(outcome, rstrip_lines=True)
 
 
@@ -42,5 +47,5 @@ def test_parsing(file_params):
     "descript,klass,arguments,content", [("no content", Rubric, "", "a")]
 )
 def test_parsing_errors(descript, klass, arguments, content):
-    with pytest.raises(DirectiveParsingError):
+    with pytest.raises(MarkupError):
         parse_directive_text(klass, arguments, content)