platypush/platypush/common/reflection/_parser/docstring.py

178 lines
5.8 KiB
Python

import re
import textwrap as tw
from contextlib import contextmanager
from typing import Optional, Dict, Callable, Generator, Any
from .._model.argument import Argument
from .._model.returns import ReturnValue
from .._serialize import Serializable
from .context import ParseContext
from .state import ParseState
class DocstringParser(Serializable):
"""
Mixin for objects that can parse docstrings.
"""
_param_doc_re = re.compile(r"^:param\s+(?P<name>[\w_]+):\s+(?P<doc>.*)$")
_type_doc_re = re.compile(r"^:type\s+[\w_]+:.*$")
_return_doc_re = re.compile(r"^:return:\s+(?P<doc>.*)$")
_default_docstring = re.compile(r"^\s*Initialize self\.\s*See help.*$")
def __init__(
self,
name: str,
doc: Optional[str] = None,
args: Optional[Dict[str, Argument]] = None,
has_varargs: bool = False,
has_kwargs: bool = False,
returns: Optional[ReturnValue] = None,
):
self.name = name
self.doc = doc
self.args = args or {}
self.has_varargs = has_varargs
self.has_kwargs = has_kwargs
self.returns = returns
def to_dict(self) -> dict:
return {
"name": self.name,
"doc": self.doc,
"args": {k: v.to_dict() for k, v in self.args.items()},
"has_varargs": self.has_varargs,
"has_kwargs": self.has_kwargs,
"returns": self.returns.to_dict() if self.returns else None,
}
@staticmethod
def _norm_indent(text: Optional[str]) -> Optional[str]:
"""
Normalize the indentation of a docstring.
:param text: Input docstring
:return: A representation of the docstring where all the leading spaces have been removed.
"""
if not text:
return None
lines = text.split("\n")
return (lines[0] + "\n" + tw.dedent("\n".join(lines[1:]) or "")).strip()
@classmethod
@contextmanager
def _parser(cls, obj: Callable) -> Generator[ParseContext, None, None]:
"""
Manages the parsing context manager.
:param obj: Method to parse.
:return: The parsing context.
"""
ctx = ParseContext(obj)
yield ctx
# Normalize the parameters docstring indentation
for param in ctx.parsed_params.values():
param.doc = cls._norm_indent(param.doc)
# Normalize the return docstring indentation
ctx.returns.doc = cls._norm_indent(ctx.returns.doc)
@staticmethod
def _is_continuation_line(line: str) -> bool:
return not line.strip() or line.startswith(" ")
@classmethod
def _parse_line(cls, line: str, ctx: ParseContext):
"""
Parse a single line of the docstring and updates the parse context accordingly.
:param line: Docstring line.
:param ctx: Parse context.
"""
# Ignore old in-doc type hints
if cls._type_doc_re.match(line) or (
ctx.state == ParseState.TYPE and cls._is_continuation_line(line)
):
ctx.state = ParseState.TYPE
return
# Ignore the default constructor docstring
if cls._default_docstring.match(line):
return
# Update the return type docstring if required
m = cls._return_doc_re.match(line)
if m or (ctx.state == ParseState.RETURN and cls._is_continuation_line(line)):
ctx.state = ParseState.RETURN
ctx.returns.doc = ((ctx.returns.doc + "\n") if ctx.returns.doc else "") + (
m.group("doc") if m else line
).rstrip()
return
# Create a new parameter entry if the docstring says so
m = cls._param_doc_re.match(line)
if m:
ctx.state = ParseState.PARAM
idx = len(ctx.parsed_params)
ctx.cur_param = m.group("name")
# Skip vararg/var keyword parameters
if ctx.cur_param in {ctx.spec.varkw, ctx.spec.varargs}:
return
ctx.parsed_params[ctx.cur_param] = Argument(
name=ctx.cur_param,
required=(
idx >= len(ctx.param_defaults) or ctx.param_defaults[idx] is Any
),
doc=m.group("doc"),
type=ctx.param_types.get(ctx.cur_param),
default=ctx.param_defaults[idx]
if idx < len(ctx.param_defaults) and ctx.param_defaults[idx] is not Any
else None,
)
return
# Update the current parameter docstring if required
if ctx.state == ParseState.PARAM and cls._is_continuation_line(line):
if ctx.cur_param in ctx.parsed_params:
ctx.parsed_params[ctx.cur_param].doc = (
(
(ctx.parsed_params[ctx.cur_param].doc or "")
+ "\n"
+ line.rstrip()
)
if ctx.parsed_params.get(ctx.cur_param)
and ctx.parsed_params[ctx.cur_param].doc
else ""
)
return
# Update the current docstring if required
ctx.cur_param = None
ctx.doc = ((ctx.doc + "\n") if ctx.doc else "") + line.rstrip()
ctx.state = ParseState.DOC
@classmethod
def parse(cls, obj: Callable):
"""
Parse the parameters of a class constructor or action method.
:param obj: Method to parse.
:return: The parsed parameters.
"""
with cls._parser(obj) as ctx:
for line in ctx.doc_lines:
cls._parse_line(line, ctx)
return cls(
name=obj.__name__,
doc=ctx.doc,
args=ctx.parsed_params,
has_varargs=ctx.spec.varargs is not None,
has_kwargs=ctx.spec.varkw is not None,
returns=ctx.returns,
)