3737
3838import datetime
3939import re
40+ from dataclasses import dataclass
4041from textwrap import dedent
4142from typing import Any , Callable
4243
4344import yaml
4445from docutils .parsers .rst import Directive
4546from 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
5464def 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
102116def 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
0 commit comments