Coverage for pymend\docstring_parser\rest.py: 98%
162 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-04-20 19:09 +0200
« prev ^ index » next coverage.py v7.3.2, created at 2024-04-20 19:09 +0200
1"""ReST-style docstring parsing."""
3import inspect
4import re
5from typing import Optional, Union
7from .common import (
8 DEPRECATION_KEYWORDS,
9 PARAM_KEYWORDS,
10 RAISES_KEYWORDS,
11 RETURNS_KEYWORDS,
12 YIELDS_KEYWORDS,
13 Docstring,
14 DocstringDeprecated,
15 DocstringMeta,
16 DocstringParam,
17 DocstringRaises,
18 DocstringReturns,
19 DocstringStyle,
20 DocstringYields,
21 ParseError,
22 RenderingStyle,
23 append_description,
24 split_description,
25)
28def _build_param(args: list[str], desc: str) -> DocstringParam:
29 """Build parameter entry from supplied arguments.
31 Parameters
32 ----------
33 args : list[str]
34 List of strings describing the parameter (name, type)
35 desc : str
36 String representing the parameter description.
38 Returns
39 -------
40 DocstringParam
41 The docstring object combining and structuring the raw info.
43 Raises
44 ------
45 ParseError
46 If an unexpected number of arguments were found.
47 """
48 if len(args) == 3:
49 _, type_name, arg_name = args
50 if type_name.endswith("?"):
51 is_optional = True
52 type_name = type_name[:-1]
53 else:
54 is_optional = False
55 elif len(args) == 2:
56 _, arg_name = args
57 type_name = None
58 is_optional = None
59 else:
60 msg = f"Expected one or two arguments for a {args[0]} keyword."
61 raise ParseError(msg)
63 match = re.match(r".*defaults to (.+)", desc, flags=re.DOTALL)
64 default = match[1].rstrip(".") if match else None
66 return DocstringParam(
67 args=args,
68 description=desc,
69 arg_name=arg_name,
70 type_name=type_name,
71 is_optional=is_optional,
72 default=default,
73 )
76def _build_return(args: list[str], desc: str) -> DocstringReturns:
77 """Build return entry from supplied arguments.
79 Parameters
80 ----------
81 args : list[str]
82 List of strings describing the return value (name, type)
83 desc : str
84 String representing the return description.
86 Returns
87 -------
88 DocstringReturns
89 The docstring object combining and structuring the raw info.
91 Raises
92 ------
93 ParseError
94 If an unexpected number of arguments were found.
95 """
96 if len(args) == 2:
97 type_name = args[1]
98 elif len(args) == 1:
99 type_name = None
100 else:
101 msg = f"Expected one or no arguments for a {args[0]} keyword."
102 raise ParseError(msg)
104 return DocstringReturns(
105 args=args,
106 description=desc,
107 type_name=type_name,
108 is_generator=False,
109 )
112def _build_yield(args: list[str], desc: str) -> DocstringYields:
113 """Build yield entry from supplied arguments.
115 Parameters
116 ----------
117 args : list[str]
118 List of strings describing the yielded value (name, type)
119 desc : str
120 String representing the yield value description.
122 Returns
123 -------
124 DocstringYields
125 The docstring object combining and structuring the raw info.
127 Raises
128 ------
129 ParseError
130 If an unexpected number of arguments were found.
131 """
132 if len(args) == 2:
133 type_name = args[1]
134 elif len(args) == 1:
135 type_name = None
136 else:
137 msg = f"Expected one or no arguments for a {args[0]} keyword."
138 raise ParseError(msg)
140 return DocstringYields(
141 args=args,
142 description=desc,
143 type_name=type_name,
144 is_generator=True,
145 )
148def _build_deprecation(args: list[str], desc: str) -> DocstringDeprecated:
149 """Build deprecation entry from supplied arguments.
151 Parameters
152 ----------
153 args : list[str]
154 List of strings describing the deprecation
155 desc : str
156 Actual textual description.
158 Returns
159 -------
160 DocstringDeprecated
161 The docstring object combining and structuring the raw info.
162 """
163 match = re.search(
164 r"^(?P<version>v?((?:\d+)(?:\.[0-9a-z\.]+))) (?P<desc>.+)",
165 desc,
166 flags=re.I,
167 )
168 return DocstringDeprecated(
169 args=args,
170 version=match["version"] if match else None,
171 description=match["desc"] if match else desc,
172 )
175def _build_raises(args: list[str], desc: str) -> DocstringRaises:
176 """Build raises entry from supplied arguments.
178 Parameters
179 ----------
180 args : list[str]
181 List of strings describing the raised value (name, type)
182 desc : str
183 String representing the raised value description.
185 Returns
186 -------
187 DocstringRaises
188 The docstring object combining and structuring the raw info.
190 Raises
191 ------
192 ParseError
193 If an unexpected number of arguments were found.
194 """
195 if len(args) == 2:
196 type_name = args[1]
197 elif len(args) == 1:
198 type_name = None
199 else:
200 msg = f"Expected one or no arguments for a {args[0]} keyword."
201 raise ParseError(msg)
202 return DocstringRaises(args=args, description=desc, type_name=type_name)
205def _build_meta(args: list[str], desc: str) -> DocstringMeta:
206 """Build a fottomg meta entry from supplied arguments.
208 Parameters
209 ----------
210 args : list[str]
211 List of strings describing entry.
212 desc : str
213 String representing the entry description.
215 Returns
216 -------
217 DocstringMeta
218 The docstring object combining and structuring the raw info.
219 """
220 key = args[0]
222 if key in PARAM_KEYWORDS:
223 return _build_param(args, desc)
225 if key in RETURNS_KEYWORDS:
226 return _build_return(args, desc)
228 if key in YIELDS_KEYWORDS:
229 return _build_yield(args, desc)
231 if key in DEPRECATION_KEYWORDS:
232 return _build_deprecation(args, desc)
234 if key in RAISES_KEYWORDS:
235 return _build_raises(args, desc)
237 return DocstringMeta(args=args, description=desc)
240def _get_chunks(text: str) -> tuple[str, str]:
241 """Split the text into args (key, type, ...) and description.
243 Parameters
244 ----------
245 text : str
246 Text to split into chunks.
248 Returns
249 -------
250 tuple[str, str]
251 Args and description.
252 """
253 if match := re.search("^:", text, flags=re.M):
254 return text[: match.start()], text[match.start() :]
255 return text, ""
258def _get_split_chunks(chunk: str) -> tuple[list[str], str]:
259 """Split an entry into args and description.
261 Parameters
262 ----------
263 chunk : str
264 Entry string to split.
266 Returns
267 -------
268 tuple[list[str], str]
269 Arguments of the entry and its description.
271 Raises
272 ------
273 ParseError
274 If the chunk could not be split into args and description.
275 """
276 try:
277 args_chunk, desc_chunk = chunk.lstrip(":").split(":", 1)
278 except ValueError as ex:
279 msg = f'Error parsing meta information near "{chunk}".'
280 raise ParseError(msg) from ex
281 return args_chunk.split(), desc_chunk.strip()
284def _extract_type_info(
285 docstring: Docstring, meta_chunk: str
286) -> tuple[dict[str, str], dict[Optional[str], str], dict[Optional[str], str]]:
287 """Extract type and description pairs and add other entries directly.
289 Parameters
290 ----------
291 docstring : Docstring
292 Docstring wrapper to add information to.
293 meta_chunk : str
294 Docstring text to extract information from.
296 Returns
297 -------
298 types : dict[str, str]
299 Dictionary matching parameters to their descriptions
300 rtypes : dict[Optional[str], str]
301 Dictionary matching return values to their descriptions
302 ytypes : dict[Optional[str], str]
303 Dictionary matching yielded values to their descriptions
304 """
305 types: dict[str, str] = {}
306 rtypes: dict[Optional[str], str] = {}
307 ytypes: dict[Optional[str], str] = {}
308 for chunk_match in re.finditer(r"(^:.*?)(?=^:|\Z)", meta_chunk, flags=re.S | re.M):
309 chunk = chunk_match.group(0)
310 if not chunk: 310 ↛ 311line 310 didn't jump to line 311, because the condition on line 310 was never true
311 continue
313 args, desc = _get_split_chunks(chunk)
315 if "\n" in desc:
316 first_line, rest = desc.split("\n", 1)
317 desc = first_line + "\n" + inspect.cleandoc(rest)
319 # Add special handling for :type a: typename
320 if len(args) == 2 and args[0] == "type":
321 types[args[1]] = desc
322 elif len(args) in {1, 2} and args[0] == "rtype":
323 rtypes[None if len(args) == 1 else args[1]] = desc
324 elif len(args) in {1, 2} and args[0] == "ytype":
325 ytypes[None if len(args) == 1 else args[1]] = desc
326 else:
327 docstring.meta.append(_build_meta(args, desc))
328 return types, rtypes, ytypes
331def parse(text: Optional[str]) -> Docstring:
332 """Parse the ReST-style docstring into its components.
334 Parameters
335 ----------
336 text : Optional[str]
337 docstring to parse
339 Returns
340 -------
341 Docstring
342 parsed docstring
344 Raises
345 ------
346 ParseError
347 If a section does not have two colons to be split on.
348 """
349 ret = Docstring(style=DocstringStyle.REST)
350 if not text:
351 return ret
353 text = inspect.cleandoc(text)
354 desc_chunk, meta_chunk = _get_chunks(text)
356 split_description(ret, desc_chunk)
358 types, rtypes, ytypes = _extract_type_info(ret, meta_chunk)
360 for meta in ret.meta:
361 if isinstance(meta, DocstringParam):
362 meta.type_name = meta.type_name or types.get(meta.arg_name)
363 elif isinstance(meta, DocstringReturns):
364 meta.type_name = meta.type_name or rtypes.get(meta.return_name)
365 elif isinstance(meta, DocstringYields):
366 meta.type_name = meta.type_name or ytypes.get(meta.yield_name)
368 if not any(isinstance(m, DocstringReturns) for m in ret.meta) and rtypes:
369 for return_name, type_name in rtypes.items():
370 ret.meta.append(
371 DocstringReturns(
372 args=[],
373 type_name=type_name,
374 description=None,
375 is_generator=False,
376 return_name=return_name,
377 )
378 )
380 return ret
383def process_desc(
384 desc: Optional[str], rendering_style: RenderingStyle, indent: str = " "
385) -> str:
386 """Process the description for one element.
388 Parameters
389 ----------
390 desc : Optional[str]
391 Description to process
392 rendering_style : RenderingStyle
393 Rendering style to use.
394 indent : str
395 Indentation needed for that line (Default value = ' ')
397 Returns
398 -------
399 str
400 String representation of the docstrings description.
401 """
402 if not desc:
403 return ""
405 if rendering_style == RenderingStyle.CLEAN:
406 (first, *rest) = desc.splitlines()
407 return "\n".join([f" {first}"] + [indent + line for line in rest])
409 if rendering_style == RenderingStyle.EXPANDED:
410 (first, *rest) = desc.splitlines()
411 return "\n".join(["\n" + indent + first] + [indent + line for line in rest])
413 return f" {desc}"
416def _append_param(
417 param: DocstringParam,
418 parts: list[str],
419 rendering_style: RenderingStyle,
420 indent: str,
421) -> None:
422 """Append one parameter entry to the output stream.
424 Parameters
425 ----------
426 param : DocstringParam
427 Structured representation of a parameter entry.
428 parts : list[str]
429 List of strings representing the final output of compose().
430 rendering_style : RenderingStyle
431 Rendering style to use.
432 indent : str
433 Indentation needed for that line.
434 """
435 if param.type_name:
436 type_text = (
437 f" {param.type_name}? " if param.is_optional else f" {param.type_name} "
438 )
439 else:
440 type_text = " "
441 if rendering_style == RenderingStyle.EXPANDED:
442 text = f":param {param.arg_name}:"
443 text += process_desc(param.description, rendering_style, indent)
444 parts.append(text)
445 if type_text[:-1]: 445 ↛ exitline 445 didn't return from function '_append_param', because the condition on line 445 was never false
446 parts.append(f":type {param.arg_name}:{type_text[:-1]}")
447 else:
448 text = f":param{type_text}{param.arg_name}:"
449 text += process_desc(param.description, rendering_style, indent)
450 parts.append(text)
453def _append_return(
454 meta: Union[DocstringReturns, DocstringYields],
455 parts: list[str],
456 rendering_style: RenderingStyle,
457 indent: str,
458) -> None:
459 """Append one return/yield entry to the output stream.
461 Parameters
462 ----------
463 meta : Union[DocstringReturns, DocstringYields]
464 Structured representation of a return/yield entry.
465 parts : list[str]
466 List of strings representing the final output of compose().
467 rendering_style : RenderingStyle
468 Rendering style to use.
469 indent : str
470 Indentation needed for that line.
471 """
472 type_text = f" {meta.type_name}" if meta.type_name else ""
473 key = "yields" if isinstance(meta, DocstringYields) else "returns"
475 if rendering_style == RenderingStyle.EXPANDED:
476 if meta.description:
477 text = f":{key}:"
478 text += process_desc(meta.description, rendering_style, indent)
479 parts.append(text)
480 if type_text: 480 ↛ exitline 480 didn't return from function '_append_return', because the condition on line 480 was never false
481 return_key = "rtype" if isinstance(meta, DocstringReturns) else "ytype"
482 parts.append(f":{return_key}:{type_text}")
483 else:
484 text = f":{key}{type_text}:"
485 text += process_desc(meta.description, rendering_style, indent)
486 parts.append(text)
489def compose(
490 docstring: Docstring,
491 rendering_style: RenderingStyle = RenderingStyle.COMPACT,
492 indent: str = " ",
493) -> str:
494 """Render a parsed docstring into docstring text.
496 Parameters
497 ----------
498 docstring : Docstring
499 parsed docstring representation
500 rendering_style : RenderingStyle
501 the style to render docstrings (Default value = RenderingStyle.COMPACT)
502 indent : str
503 the characters used as indentation in the docstring string
504 (Default value = ' ')
506 Returns
507 -------
508 str
509 docstring text
510 """
511 parts: list[str] = []
512 append_description(docstring, parts)
514 for meta in docstring.meta:
515 if isinstance(meta, DocstringParam):
516 _append_param(meta, parts, rendering_style, indent)
517 elif isinstance(meta, (DocstringReturns, DocstringYields)):
518 _append_return(meta, parts, rendering_style, indent)
519 elif isinstance(meta, DocstringRaises):
520 type_text = f" {meta.type_name} " if meta.type_name else ""
521 text = (
522 f":raises{type_text}:"
523 f"{process_desc(meta.description, rendering_style, indent)}"
524 )
525 parts.append(text)
526 else:
527 text = (
528 f":{' '.join(meta.args)}:"
529 f"{process_desc(meta.description, rendering_style, indent)}"
530 )
531 parts.append(text)
532 return "\n".join(parts)