feat(phase1): add image storage utilities

- Create backend/services/image_storage.py with 4 core functions:
  - sanitize_filename(): remove unsafe chars, limit to 255 chars, convert to lowercase
  - get_unique_filename(): handle collisions with UUID suffix (format: {name}_{uuid8}_{variant}.jpg)
  - ensure_image_directories(): create /images/ root and category subdirs on startup
  - save_image(): save bytes to /images/{category}/{filename}, returns relative path
- Create comprehensive test suite (22 tests) covering all functionality
- Integrate ensure_image_directories() into FastAPI startup event
- Directory structure: /images/{category}/{filename}
- Collision handling: auto-suffix with UUID if filename exists
- All tests passing, pathlib.Path for safe operations
This commit is contained in:
2026-04-20 21:57:26 +03:00
parent 39fab336ba
commit ea49cd6e4a
4487 changed files with 1305959 additions and 4 deletions

View File

@@ -0,0 +1,34 @@
"""Parse docstrings as per Sphinx notation."""
from .common import (
Docstring,
DocstringDeprecated,
DocstringMeta,
DocstringParam,
DocstringRaises,
DocstringReturns,
DocstringStyle,
ParseError,
RenderingStyle,
)
from .parser import compose, parse, parse_from_object
from .util import combine_docstrings
Style = DocstringStyle # backwards compatibility
__all__ = [
"parse",
"parse_from_object",
"combine_docstrings",
"compose",
"ParseError",
"Docstring",
"DocstringMeta",
"DocstringParam",
"DocstringRaises",
"DocstringReturns",
"DocstringDeprecated",
"DocstringStyle",
"RenderingStyle",
"Style",
]

View File

@@ -0,0 +1,126 @@
"""Attribute docstrings parsing.
.. seealso:: https://peps.python.org/pep-0257/#what-is-a-docstring
"""
import ast
import inspect
import textwrap
import typing as T
from types import ModuleType
from .common import Docstring, DocstringParam
def ast_get_constant_value(node: ast.AST) -> T.Any:
"""Return the constant's value if the given node is a constant."""
return getattr(node, "value")
def ast_unparse(node: ast.AST) -> T.Optional[str]:
"""Convert the AST node to source code as a string."""
if hasattr(ast, "unparse"):
return ast.unparse(node)
# Support simple cases in Python < 3.9
if isinstance(node, ast.Constant):
return str(ast_get_constant_value(node))
if isinstance(node, ast.Name):
return node.id
return None
def ast_is_literal_str(node: ast.AST) -> bool:
"""Return True if the given node is a literal string."""
return (
isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant)
and isinstance(ast_get_constant_value(node.value), str)
)
def ast_get_attribute(
node: ast.AST,
) -> T.Optional[T.Tuple[str, T.Optional[str], T.Optional[str]]]:
"""Return name, type and default if the given node is an attribute."""
if isinstance(node, (ast.Assign, ast.AnnAssign)):
target = (
node.targets[0] if isinstance(node, ast.Assign) else node.target
)
if isinstance(target, ast.Name):
type_str = None
if isinstance(node, ast.AnnAssign):
type_str = ast_unparse(node.annotation)
default = None
if node.value:
default = ast_unparse(node.value)
return target.id, type_str, default
return None
class AttributeDocstrings(ast.NodeVisitor):
"""An ast.NodeVisitor that collects attribute docstrings."""
attr_docs = None
prev_attr = None
def visit(self, node):
if self.prev_attr and ast_is_literal_str(node):
attr_name, attr_type, attr_default = self.prev_attr
self.attr_docs[attr_name] = (
ast_get_constant_value(node.value),
attr_type,
attr_default,
)
self.prev_attr = ast_get_attribute(node)
if isinstance(node, (ast.ClassDef, ast.Module)):
self.generic_visit(node)
def get_attr_docs(
self, component: T.Any
) -> T.Dict[str, T.Tuple[str, T.Optional[str], T.Optional[str]]]:
"""Get attribute docstrings from the given component.
:param component: component to process (class or module)
:returns: for each attribute docstring, a tuple with (description,
type, default)
"""
self.attr_docs = {}
self.prev_attr = None
try:
source = textwrap.dedent(inspect.getsource(component))
except OSError:
pass
else:
tree = ast.parse(source)
if inspect.ismodule(component):
self.visit(tree)
elif isinstance(tree, ast.Module) and isinstance(
tree.body[0], ast.ClassDef
):
self.visit(tree.body[0])
return self.attr_docs
def add_attribute_docstrings(
obj: T.Union[type, ModuleType], docstring: Docstring
) -> None:
"""Add attribute docstrings found in the object's source code.
:param obj: object from which to parse attribute docstrings
:param docstring: Docstring object where found attributes are added
:returns: list with names of added attributes
"""
params = set(p.arg_name for p in docstring.params)
for arg_name, (description, type_name, default) in (
AttributeDocstrings().get_attr_docs(obj).items()
):
if arg_name not in params:
param = DocstringParam(
args=["attribute", arg_name],
description=description,
arg_name=arg_name,
type_name=type_name,
is_optional=default is not None,
default=default,
)
docstring.meta.append(param)

View File

@@ -0,0 +1,229 @@
"""Common methods for parsing."""
import enum
import typing as T
PARAM_KEYWORDS = {
"param",
"parameter",
"arg",
"argument",
"attribute",
"key",
"keyword",
}
RAISES_KEYWORDS = {"raises", "raise", "except", "exception"}
DEPRECATION_KEYWORDS = {"deprecation", "deprecated"}
RETURNS_KEYWORDS = {"return", "returns"}
YIELDS_KEYWORDS = {"yield", "yields"}
EXAMPLES_KEYWORDS = {"example", "examples"}
class ParseError(RuntimeError):
"""Base class for all parsing related errors."""
class DocstringStyle(enum.Enum):
"""Docstring style."""
REST = 1
GOOGLE = 2
NUMPYDOC = 3
EPYDOC = 4
AUTO = 255
class RenderingStyle(enum.Enum):
"""Rendering style when unparsing parsed docstrings."""
COMPACT = 1
CLEAN = 2
EXPANDED = 3
class DocstringMeta:
"""Docstring meta information.
Symbolizes lines in form of
:param arg: description
:raises ValueError: if something happens
"""
def __init__(
self, args: T.List[str], description: T.Optional[str]
) -> None:
"""Initialize self.
:param args: list of arguments. The exact content of this variable is
dependent on the kind of docstring; it's used to distinguish
between custom docstring meta information items.
:param description: associated docstring description.
"""
self.args = args
self.description = description
class DocstringParam(DocstringMeta):
"""DocstringMeta symbolizing :param metadata."""
def __init__(
self,
args: T.List[str],
description: T.Optional[str],
arg_name: str,
type_name: T.Optional[str],
is_optional: T.Optional[bool],
default: T.Optional[str],
) -> None:
"""Initialize self."""
super().__init__(args, description)
self.arg_name = arg_name
self.type_name = type_name
self.is_optional = is_optional
self.default = default
class DocstringReturns(DocstringMeta):
"""DocstringMeta symbolizing :returns or :yields metadata."""
def __init__(
self,
args: T.List[str],
description: T.Optional[str],
type_name: T.Optional[str],
is_generator: bool,
return_name: T.Optional[str] = None,
) -> None:
"""Initialize self."""
super().__init__(args, description)
self.type_name = type_name
self.is_generator = is_generator
self.return_name = return_name
class DocstringRaises(DocstringMeta):
"""DocstringMeta symbolizing :raises metadata."""
def __init__(
self,
args: T.List[str],
description: T.Optional[str],
type_name: T.Optional[str],
) -> None:
"""Initialize self."""
super().__init__(args, description)
self.type_name = type_name
self.description = description
class DocstringDeprecated(DocstringMeta):
"""DocstringMeta symbolizing deprecation metadata."""
def __init__(
self,
args: T.List[str],
description: T.Optional[str],
version: T.Optional[str],
) -> None:
"""Initialize self."""
super().__init__(args, description)
self.version = version
self.description = description
class DocstringExample(DocstringMeta):
"""DocstringMeta symbolizing example metadata."""
def __init__(
self,
args: T.List[str],
snippet: T.Optional[str],
description: T.Optional[str],
) -> None:
"""Initialize self."""
super().__init__(args, description)
self.snippet = snippet
self.description = description
class Docstring:
"""Docstring object representation."""
def __init__(
self,
style=None, # type: T.Optional[DocstringStyle]
) -> None:
"""Initialize self."""
self.short_description = None # type: T.Optional[str]
self.long_description = None # type: T.Optional[str]
self.blank_after_short_description = False
self.blank_after_long_description = False
self.meta = [] # type: T.List[DocstringMeta]
self.style = style # type: T.Optional[DocstringStyle]
@property
def description(self) -> T.Optional[str]:
"""Return the full description of the function
Returns None if the docstring did not include any description
"""
ret = []
if self.short_description:
ret.append(self.short_description)
if self.blank_after_short_description:
ret.append("")
if self.long_description:
ret.append(self.long_description)
if not ret:
return None
return "\n".join(ret)
@property
def params(self) -> T.List[DocstringParam]:
"""Return a list of information on function params."""
return [item for item in self.meta if isinstance(item, DocstringParam)]
@property
def raises(self) -> T.List[DocstringRaises]:
"""Return a list of information on the exceptions that the function
may raise.
"""
return [
item for item in self.meta if isinstance(item, DocstringRaises)
]
@property
def returns(self) -> T.Optional[DocstringReturns]:
"""Return a single information on function return.
Takes the first return information.
"""
for item in self.meta:
if isinstance(item, DocstringReturns):
return item
return None
@property
def many_returns(self) -> T.List[DocstringReturns]:
"""Return a list of information on function return."""
return [
item for item in self.meta if isinstance(item, DocstringReturns)
]
@property
def deprecation(self) -> T.Optional[DocstringDeprecated]:
"""Return a single information on function deprecation notes."""
for item in self.meta:
if isinstance(item, DocstringDeprecated):
return item
return None
@property
def examples(self) -> T.List[DocstringExample]:
"""Return a list of information on function examples."""
return [
item for item in self.meta if isinstance(item, DocstringExample)
]

View File

@@ -0,0 +1,284 @@
"""Epyoc-style docstring parsing.
.. seealso:: http://epydoc.sourceforge.net/manual-fields.html
"""
import inspect
import re
import typing as T
from .common import (
Docstring,
DocstringMeta,
DocstringParam,
DocstringRaises,
DocstringReturns,
DocstringStyle,
ParseError,
RenderingStyle,
)
def _clean_str(string: str) -> T.Optional[str]:
string = string.strip()
if len(string) > 0:
return string
return None
def parse(text: T.Optional[str]) -> Docstring:
"""Parse the epydoc-style docstring into its components.
:returns: parsed docstring
"""
ret = Docstring(style=DocstringStyle.EPYDOC)
if not text:
return ret
text = inspect.cleandoc(text)
match = re.search("^@", text, flags=re.M)
if match:
desc_chunk = text[: match.start()]
meta_chunk = text[match.start() :]
else:
desc_chunk = text
meta_chunk = ""
parts = desc_chunk.split("\n", 1)
ret.short_description = parts[0] or None
if len(parts) > 1:
long_desc_chunk = parts[1] or ""
ret.blank_after_short_description = long_desc_chunk.startswith("\n")
ret.blank_after_long_description = long_desc_chunk.endswith("\n\n")
ret.long_description = long_desc_chunk.strip() or None
param_pattern = re.compile(
r"(param|keyword|type)(\s+[_A-z][_A-z0-9]*\??):"
)
attribute_pattern = re.compile(r"(ivar|cvar|var)(\s+[_A-z][_A-z0-9]*\??):")
raise_pattern = re.compile(r"(raise)(\s+[_A-z][_A-z0-9]*\??)?:")
return_pattern = re.compile(r"(return|rtype|yield|ytype):")
meta_pattern = re.compile(
r"([_A-z][_A-z0-9]+)((\s+[_A-z][_A-z0-9]*\??)*):"
)
# tokenize
stream: T.List[T.Tuple[str, str, T.List[str], str]] = []
for match in re.finditer(
r"(^@.*?)(?=^@|\Z)", meta_chunk, flags=re.S | re.M
):
chunk = match.group(0)
if not chunk:
continue
param_match = re.search(param_pattern, chunk)
attribute_match = re.search(attribute_pattern, chunk)
raise_match = re.search(raise_pattern, chunk)
return_match = re.search(return_pattern, chunk)
meta_match = re.search(meta_pattern, chunk)
match = (
param_match
or attribute_match
or raise_match
or return_match
or meta_match
)
if not match:
raise ParseError(f'Error parsing meta information near "{chunk}".')
desc_chunk = chunk[match.end() :]
if param_match:
base = "param"
key: str = match.group(1)
args = [match.group(2).strip()]
elif attribute_match:
base = "attribute"
key: str = match.group(1)
args = [match.group(2).strip()]
elif raise_match:
base = "raise"
key: str = match.group(1)
args = [] if match.group(2) is None else [match.group(2).strip()]
elif return_match:
base = "return"
key: str = match.group(1)
args = []
else:
base = "meta"
key: str = match.group(1)
token = _clean_str(match.group(2).strip())
args = [] if token is None else re.split(r"\s+", token)
# Make sure we didn't match some existing keyword in an incorrect
# way here:
if key in [
"param",
"ivar",
"cvar",
"var",
"keyword",
"type",
"return",
"rtype",
"yield",
"ytype",
]:
raise ParseError(
f'Error parsing meta information near "{chunk}".'
)
desc = desc_chunk.strip()
if "\n" in desc:
first_line, rest = desc.split("\n", 1)
desc = first_line + "\n" + inspect.cleandoc(rest)
stream.append((base, key, args, desc))
# Combine type_name, arg_name, and description information
params: T.Dict[str, T.Dict[str, T.Any]] = {}
for base, key, args, desc in stream:
if base not in ["param", "attribute", "return"]:
continue # nothing to do
(arg_name,) = args or ("return",)
info = params.setdefault(arg_name, {})
info_key = "type_name" if "type" in key else "description"
info[info_key] = desc
if base == "return":
is_generator = key in {"ytype", "yield"}
if info.setdefault("is_generator", is_generator) != is_generator:
raise ParseError(
f'Error parsing meta information for "{arg_name}".'
)
is_done: T.Dict[str, bool] = {}
for base, key, args, desc in stream:
if base in ["param", "attribute"] and not is_done.get(args[0], False):
(arg_name,) = args
info = params[arg_name]
type_name = info.get("type_name")
if type_name and type_name.endswith("?"):
is_optional = True
type_name = type_name[:-1]
else:
is_optional = False
match = re.match(r".*defaults to (.+)", desc, flags=re.DOTALL)
default = match.group(1).rstrip(".") if match else None
meta_item = DocstringParam(
args=[key, arg_name],
description=info.get("description"),
arg_name=arg_name,
type_name=type_name,
is_optional=is_optional,
default=default,
)
is_done[arg_name] = True
elif base == "return" and not is_done.get("return", False):
info = params["return"]
meta_item = DocstringReturns(
args=[key],
description=info.get("description"),
type_name=info.get("type_name"),
is_generator=info.get("is_generator", False),
)
is_done["return"] = True
elif base == "raise":
(type_name,) = args or (None,)
meta_item = DocstringRaises(
args=[key] + args,
description=desc,
type_name=type_name,
)
elif base == "meta":
meta_item = DocstringMeta(
args=[key] + args,
description=desc,
)
else:
(key, *_) = args or ("return",)
assert is_done.get(key, False)
continue # don't append
ret.meta.append(meta_item)
return ret
def compose(
docstring: Docstring,
rendering_style: RenderingStyle = RenderingStyle.COMPACT,
indent: str = " ",
) -> str:
"""Render a parsed docstring into docstring text.
:param docstring: parsed docstring representation
:param rendering_style: the style to render docstrings
:param indent: the characters used as indentation in the docstring string
:returns: docstring text
"""
def process_desc(desc: T.Optional[str], is_type: bool) -> str:
if not desc:
return ""
if rendering_style == RenderingStyle.EXPANDED or (
rendering_style == RenderingStyle.CLEAN and not is_type
):
(first, *rest) = desc.splitlines()
return "\n".join(
["\n" + indent + first] + [indent + line for line in rest]
)
(first, *rest) = desc.splitlines()
return "\n".join([" " + first] + [indent + line for line in rest])
parts: T.List[str] = []
if docstring.short_description:
parts.append(docstring.short_description)
if docstring.blank_after_short_description:
parts.append("")
if docstring.long_description:
parts.append(docstring.long_description)
if docstring.blank_after_long_description:
parts.append("")
for meta in docstring.meta:
if isinstance(meta, DocstringParam):
if meta.type_name:
type_name = (
f"{meta.type_name}?"
if meta.is_optional
else meta.type_name
)
text = f"@type {meta.arg_name}:"
text += process_desc(type_name, True)
parts.append(text)
text = f"@param {meta.arg_name}:" + process_desc(
meta.description, False
)
parts.append(text)
elif isinstance(meta, DocstringReturns):
(arg_key, type_key) = (
("yield", "ytype")
if meta.is_generator
else ("return", "rtype")
)
if meta.type_name:
text = f"@{type_key}:" + process_desc(meta.type_name, True)
parts.append(text)
if meta.description:
text = f"@{arg_key}:" + process_desc(meta.description, False)
parts.append(text)
elif isinstance(meta, DocstringRaises):
text = f"@raise {meta.type_name}:" if meta.type_name else "@raise:"
text += process_desc(meta.description, False)
parts.append(text)
else:
text = f'@{" ".join(meta.args)}:'
text += process_desc(meta.description, False)
parts.append(text)
return "\n".join(parts)

View File

@@ -0,0 +1,414 @@
"""Google-style docstring parsing."""
import inspect
import re
import typing as T
from collections import OrderedDict, namedtuple
from enum import IntEnum
from .common import (
EXAMPLES_KEYWORDS,
PARAM_KEYWORDS,
RAISES_KEYWORDS,
RETURNS_KEYWORDS,
YIELDS_KEYWORDS,
Docstring,
DocstringExample,
DocstringMeta,
DocstringParam,
DocstringRaises,
DocstringReturns,
DocstringStyle,
ParseError,
RenderingStyle,
)
class SectionType(IntEnum):
"""Types of sections."""
SINGULAR = 0
"""For sections like examples."""
MULTIPLE = 1
"""For sections like params."""
SINGULAR_OR_MULTIPLE = 2
"""For sections like returns or yields."""
class Section(namedtuple("SectionBase", "title key type")):
"""A docstring section."""
GOOGLE_TYPED_ARG_REGEX = re.compile(r"\s*(.+?)\s*\(\s*(.*[^\s]+)\s*\)")
GOOGLE_ARG_DESC_REGEX = re.compile(r".*\. Defaults to (.+)\.")
MULTIPLE_PATTERN = re.compile(r"(\s*[^:\s]+:)|([^:]*\]:.*)")
DEFAULT_SECTIONS = [
Section("Arguments", "param", SectionType.MULTIPLE),
Section("Args", "param", SectionType.MULTIPLE),
Section("Parameters", "param", SectionType.MULTIPLE),
Section("Params", "param", SectionType.MULTIPLE),
Section("Raises", "raises", SectionType.MULTIPLE),
Section("Exceptions", "raises", SectionType.MULTIPLE),
Section("Except", "raises", SectionType.MULTIPLE),
Section("Attributes", "attribute", SectionType.MULTIPLE),
Section("Example", "examples", SectionType.SINGULAR),
Section("Examples", "examples", SectionType.SINGULAR),
Section("Returns", "returns", SectionType.SINGULAR_OR_MULTIPLE),
Section("Yields", "yields", SectionType.SINGULAR_OR_MULTIPLE),
]
class GoogleParser:
"""Parser for Google-style docstrings."""
def __init__(
self, sections: T.Optional[T.List[Section]] = None, title_colon=True
):
"""Setup sections.
:param sections: Recognized sections or None to defaults.
:param title_colon: require colon after section title.
"""
if not sections:
sections = DEFAULT_SECTIONS
self.sections = {s.title: s for s in sections}
self.title_colon = title_colon
self._setup()
def _setup(self):
if self.title_colon:
colon = ":"
else:
colon = ""
self.titles_re = re.compile(
"^("
+ "|".join(f"({t})" for t in self.sections)
+ ")"
+ colon
+ "[ \t\r\f\v]*$",
flags=re.M,
)
def _build_meta(self, text: str, title: str) -> DocstringMeta:
"""Build docstring element.
:param text: docstring element text
:param title: title of section containing element
:return:
"""
section = self.sections[title]
if (
section.type == SectionType.SINGULAR_OR_MULTIPLE
and not MULTIPLE_PATTERN.match(text)
) or section.type == SectionType.SINGULAR:
return self._build_single_meta(section, text)
if ":" not in text:
raise ParseError(f"Expected a colon in {text!r}.")
# Split spec and description
before, desc = text.split(":", 1)
if before and "\n" in before:
# If there is a newline in the first line, clean it up
first_line, rest = before.split("\n", 1)
before = first_line + inspect.cleandoc(rest)
if desc:
desc = desc[1:] if desc[0] == " " else desc
if "\n" in desc:
first_line, rest = desc.split("\n", 1)
desc = first_line + "\n" + inspect.cleandoc(rest)
desc = desc.strip("\n")
return self._build_multi_meta(section, before, desc)
@staticmethod
def _build_single_meta(section: Section, desc: str) -> DocstringMeta:
if section.key in RETURNS_KEYWORDS | YIELDS_KEYWORDS:
return DocstringReturns(
args=[section.key],
description=desc,
type_name=None,
is_generator=section.key in YIELDS_KEYWORDS,
)
if section.key in RAISES_KEYWORDS:
return DocstringRaises(
args=[section.key], description=desc, type_name=None
)
if section.key in EXAMPLES_KEYWORDS:
return DocstringExample(
args=[section.key], snippet=None, description=desc
)
if section.key in PARAM_KEYWORDS:
raise ParseError("Expected paramenter name.")
return DocstringMeta(args=[section.key], description=desc)
@staticmethod
def _build_multi_meta(
section: Section, before: str, desc: str
) -> DocstringMeta:
if section.key in PARAM_KEYWORDS:
match = GOOGLE_TYPED_ARG_REGEX.match(before)
if match:
arg_name, type_name = match.group(1, 2)
if type_name.endswith(", optional"):
is_optional = True
type_name = type_name[:-10]
elif type_name.endswith("?"):
is_optional = True
type_name = type_name[:-1]
else:
is_optional = False
else:
arg_name, type_name = before, None
is_optional = None
match = GOOGLE_ARG_DESC_REGEX.match(desc)
default = match.group(1) if match else None
return DocstringParam(
args=[section.key, before],
description=desc,
arg_name=arg_name,
type_name=type_name,
is_optional=is_optional,
default=default,
)
if section.key in RETURNS_KEYWORDS | YIELDS_KEYWORDS:
return DocstringReturns(
args=[section.key, before],
description=desc,
type_name=before,
is_generator=section.key in YIELDS_KEYWORDS,
)
if section.key in RAISES_KEYWORDS:
return DocstringRaises(
args=[section.key, before], description=desc, type_name=before
)
return DocstringMeta(args=[section.key, before], description=desc)
def add_section(self, section: Section):
"""Add or replace a section.
:param section: The new section.
"""
self.sections[section.title] = section
self._setup()
def parse(self, text: T.Optional[str]) -> Docstring:
"""Parse the Google-style docstring into its components.
:returns: parsed docstring
"""
ret = Docstring(style=DocstringStyle.GOOGLE)
if not text:
return ret
# Clean according to PEP-0257
text = inspect.cleandoc(text)
# Find first title and split on its position
match = self.titles_re.search(text)
if match:
desc_chunk = text[: match.start()]
meta_chunk = text[match.start() :]
else:
desc_chunk = text
meta_chunk = ""
# Break description into short and long parts
parts = desc_chunk.split("\n", 1)
ret.short_description = parts[0] or None
if len(parts) > 1:
long_desc_chunk = parts[1] or ""
ret.blank_after_short_description = long_desc_chunk.startswith(
"\n"
)
ret.blank_after_long_description = long_desc_chunk.endswith("\n\n")
ret.long_description = long_desc_chunk.strip() or None
# Split by sections determined by titles
matches = list(self.titles_re.finditer(meta_chunk))
if not matches:
return ret
splits = []
for j in range(len(matches) - 1):
splits.append((matches[j].end(), matches[j + 1].start()))
splits.append((matches[-1].end(), len(meta_chunk)))
chunks = OrderedDict() # type: T.Mapping[str,str]
for j, (start, end) in enumerate(splits):
title = matches[j].group(1)
if title not in self.sections:
continue
# Clear Any Unknown Meta
# Ref: https://github.com/rr-/docstring_parser/issues/29
meta_details = meta_chunk[start:end]
unknown_meta = re.search(r"\n\S", meta_details)
if unknown_meta is not None:
meta_details = meta_details[: unknown_meta.start()]
chunks[title] = meta_details.strip("\n")
if not chunks:
return ret
# Add elements from each chunk
for title, chunk in chunks.items():
# Determine indent
indent_match = re.search(r"^\s*", chunk)
if not indent_match:
raise ParseError(f'Can\'t infer indent from "{chunk}"')
indent = indent_match.group()
# Check for singular elements
if self.sections[title].type in [
SectionType.SINGULAR,
SectionType.SINGULAR_OR_MULTIPLE,
]:
part = inspect.cleandoc(chunk)
ret.meta.append(self._build_meta(part, title))
continue
# Split based on lines which have exactly that indent
_re = "^" + indent + r"(?=\S)"
c_matches = list(re.finditer(_re, chunk, flags=re.M))
if not c_matches:
raise ParseError(f'No specification for "{title}": "{chunk}"')
c_splits = []
for j in range(len(c_matches) - 1):
c_splits.append((c_matches[j].end(), c_matches[j + 1].start()))
c_splits.append((c_matches[-1].end(), len(chunk)))
for j, (start, end) in enumerate(c_splits):
part = chunk[start:end].strip("\n")
ret.meta.append(self._build_meta(part, title))
return ret
def parse(text: T.Optional[str]) -> Docstring:
"""Parse the Google-style docstring into its components.
:returns: parsed docstring
"""
return GoogleParser().parse(text)
def compose(
docstring: Docstring,
rendering_style: RenderingStyle = RenderingStyle.COMPACT,
indent: str = " ",
) -> str:
"""Render a parsed docstring into docstring text.
:param docstring: parsed docstring representation
:param rendering_style: the style to render docstrings
:param indent: the characters used as indentation in the docstring string
:returns: docstring text
"""
def process_one(
one: T.Union[DocstringParam, DocstringReturns, DocstringRaises],
):
head = ""
if isinstance(one, DocstringParam):
head += one.arg_name or ""
elif isinstance(one, DocstringReturns):
head += one.return_name or ""
if isinstance(one, DocstringParam) and one.is_optional:
optional = (
"?"
if rendering_style == RenderingStyle.COMPACT
else ", optional"
)
else:
optional = ""
if one.type_name and head:
head += f" ({one.type_name}{optional}):"
elif one.type_name:
head += f"{one.type_name}{optional}:"
else:
head += ":"
head = indent + head
if one.description and rendering_style == RenderingStyle.EXPANDED:
body = f"\n{indent}{indent}".join(
[head] + one.description.splitlines()
)
parts.append(body)
elif one.description:
(first, *rest) = one.description.splitlines()
body = f"\n{indent}{indent}".join([head + " " + first] + rest)
parts.append(body)
else:
parts.append(head)
def process_sect(name: str, args: T.List[T.Any]):
if args:
parts.append(name)
for arg in args:
process_one(arg)
parts.append("")
parts: T.List[str] = []
if docstring.short_description:
parts.append(docstring.short_description)
if docstring.blank_after_short_description:
parts.append("")
if docstring.long_description:
parts.append(docstring.long_description)
if docstring.blank_after_long_description:
parts.append("")
process_sect(
"Args:", [p for p in docstring.params or [] if p.args[0] == "param"]
)
process_sect(
"Attributes:",
[p for p in docstring.params or [] if p.args[0] == "attribute"],
)
process_sect(
"Returns:",
[p for p in docstring.many_returns or [] if not p.is_generator],
)
process_sect(
"Yields:", [p for p in docstring.many_returns or [] if p.is_generator]
)
process_sect("Raises:", docstring.raises or [])
if docstring.returns and not docstring.many_returns:
ret = docstring.returns
parts.append("Yields:" if ret else "Returns:")
parts.append("-" * len(parts[-1]))
process_one(ret)
for meta in docstring.meta:
if isinstance(
meta, (DocstringParam, DocstringReturns, DocstringRaises)
):
continue # Already handled
parts.append(meta.args[0].replace("_", "").title() + ":")
if meta.description:
lines = [indent + l for l in meta.description.splitlines()]
parts.append("\n".join(lines))
parts.append("")
while parts and not parts[-1]:
parts.pop()
return "\n".join(parts)

View File

@@ -0,0 +1,568 @@
"""Numpydoc-style docstring parsing.
:see: https://numpydoc.readthedocs.io/en/latest/format.html
"""
import inspect
import itertools
import re
import typing as T
from textwrap import dedent
from .common import (
Docstring,
DocstringDeprecated,
DocstringExample,
DocstringMeta,
DocstringParam,
DocstringRaises,
DocstringReturns,
DocstringStyle,
RenderingStyle,
)
def _pairwise(iterable: T.Iterable, end=None) -> T.Iterable:
left, right = itertools.tee(iterable)
next(right, None)
return itertools.zip_longest(left, right, fillvalue=end)
def _clean_str(string: str) -> T.Optional[str]:
string = string.strip()
if len(string) > 0:
return string
return None
KV_REGEX = re.compile(r"^[^\s].*$", flags=re.M)
PARAM_KEY_REGEX = re.compile(r"^(?P<name>.*?)(?:\s*:\s*(?P<type>.*?))?$")
PARAM_OPTIONAL_REGEX = re.compile(r"(?P<type>.*?)(?:, optional|\(optional\))$")
# Ideally, default value will be specified in the type declaration,
# for which the following are supported:
#
# copy : bool, default True
# copy : bool, default=True
# copy : bool, default: True
#
PARAM_DEFAULT_REGEX = re.compile(
r"(?P<type>.*?)(?:, default|\(default\))(?: | |=| = |= |: |)*(?P<value>.*)$" # pylint: disable=C0301
)
# If the default value isn't specified in the type declaration,
# it might be in the description. There isn't any formal grammar for this
# in numpydoc, but we can make some educated guesses.
PARAM_DEFAULT_REGEX_IN_DESC = re.compile(
r"(?<!\S)[Dd]efault(?:s to |(?:\s*(?:is|[=:])\s*|\s+))(?P<value>(?:['\"]).*?(?:['\"])|[\w\-\.]*\w)" # pylint: disable=C0301
)
RETURN_KEY_REGEX = re.compile(r"^(?:(?P<name>.*?)\s*:\s*)?(?P<type>.*?)$")
class Section:
"""Numpydoc section parser.
:param title: section title. For most sections, this is a heading like
"Parameters" which appears on its own line, underlined by
en-dashes ('-') on the following line.
:param key: meta key string. In the parsed ``DocstringMeta`` instance this
will be the first element of the ``args`` attribute list.
"""
def __init__(self, title: str, key: str) -> None:
self.title = title
self.key = key
@property
def title_pattern(self) -> str:
"""Regular expression pattern matching this section's header.
This pattern will match this instance's ``title`` attribute in
an anonymous group.
"""
dashes = "-" * len(self.title)
return rf"^({self.title})\s*?\n{dashes}\s*$"
def parse(self, text: str) -> T.Iterable[DocstringMeta]:
"""Parse ``DocstringMeta`` objects from the body of this section.
:param text: section body text. Should be cleaned with
``inspect.cleandoc`` before parsing.
"""
yield DocstringMeta([self.key], description=_clean_str(text))
class _KVSection(Section):
"""Base parser for numpydoc sections with key-value syntax.
E.g. sections that look like this:
key
value
key2 : type
values can also span...
... multiple lines
"""
def _parse_item(self, key: str, value: str) -> DocstringMeta:
pass
def parse(self, text: str) -> T.Iterable[DocstringMeta]:
for match, next_match in _pairwise(KV_REGEX.finditer(text)):
start = match.end()
end = next_match.start() if next_match is not None else None
value = text[start:end]
yield self._parse_item(
key=match.group(), value=inspect.cleandoc(value)
)
class _SphinxSection(Section):
"""Base parser for numpydoc sections with sphinx-style syntax.
E.g. sections that look like this:
.. title:: something
possibly over multiple lines
"""
@property
def title_pattern(self) -> str:
return rf"^\.\.\s*({self.title})\s*::"
class ParamSection(_KVSection):
"""Parser for numpydoc parameter sections.
E.g. any section that looks like this:
arg_name
arg_description
arg_2 : type, optional
descriptions can also span...
... multiple lines
"""
def _parse_item(self, key: str, value: str) -> DocstringParam:
match = PARAM_KEY_REGEX.match(key)
arg_name = type_name = is_optional = default = None
if match is not None:
arg_name = match.group("name")
type_name = match.group("type")
if type_name is not None:
optional_match = PARAM_OPTIONAL_REGEX.match(type_name)
if optional_match is not None:
type_name = optional_match.group("type")
is_optional = True
else:
is_optional = False
default_match = PARAM_DEFAULT_REGEX.match(type_name)
if default_match is not None:
is_optional = True
type_name = default_match.group("type")
default = default_match.group("value")
# If the default wasn't specifified in the type declaration,
# try and see if we can find it in the description.
if len(value) > 0 and default is None:
default_match = PARAM_DEFAULT_REGEX_IN_DESC.search(value)
if default_match is not None:
default = default_match.group("value")
return DocstringParam(
args=[self.key, arg_name],
description=_clean_str(value),
arg_name=arg_name,
type_name=type_name,
is_optional=is_optional,
default=default,
)
class RaisesSection(_KVSection):
"""Parser for numpydoc raises sections.
E.g. any section that looks like this:
ValueError
A description of what might raise ValueError
"""
def _parse_item(self, key: str, value: str) -> DocstringRaises:
return DocstringRaises(
args=[self.key, key],
description=_clean_str(value),
type_name=key if len(key) > 0 else None,
)
class ReturnsSection(_KVSection):
"""Parser for numpydoc returns sections.
E.g. any section that looks like this:
return_name : type
A description of this returned value
another_type
Return names are optional, types are required
"""
is_generator = False
def _parse_item(self, key: str, value: str) -> DocstringReturns:
match = RETURN_KEY_REGEX.match(key)
if match is not None:
return_name = match.group("name")
type_name = match.group("type")
else:
return_name = None
type_name = None
return DocstringReturns(
args=[self.key],
description=_clean_str(value),
type_name=type_name,
is_generator=self.is_generator,
return_name=return_name,
)
class YieldsSection(ReturnsSection):
"""Parser for numpydoc generator "yields" sections."""
is_generator = True
class DeprecationSection(_SphinxSection):
"""Parser for numpydoc "deprecation warning" sections."""
def parse(self, text: str) -> T.Iterable[DocstringDeprecated]:
version, desc, *_ = text.split(sep="\n", maxsplit=1) + [None, None]
if desc is not None:
desc = _clean_str(inspect.cleandoc(desc))
yield DocstringDeprecated(
args=[self.key], description=desc, version=_clean_str(version)
)
class ExamplesSection(Section):
"""Parser for numpydoc examples sections.
E.g. any section that looks like this:
>>> import numpy.matlib
>>> np.matlib.empty((2, 2)) # filled with random data
matrix([[ 6.76425276e-320, 9.79033856e-307], # random
[ 7.39337286e-309, 3.22135945e-309]])
>>> np.matlib.empty((2, 2), dtype=int)
matrix([[ 6600475, 0], # random
[ 6586976, 22740995]])
"""
def parse(self, text: str) -> T.Iterable[DocstringMeta]:
"""Parse ``DocstringExample`` objects from the body of this section.
:param text: section body text. Should be cleaned with
``inspect.cleandoc`` before parsing.
"""
lines = dedent(text).strip().splitlines()
while lines:
snippet_lines = []
description_lines = []
while lines:
if not lines[0].startswith(">>>"):
break
snippet_lines.append(lines.pop(0))
while lines:
if lines[0].startswith(">>>"):
break
description_lines.append(lines.pop(0))
yield DocstringExample(
[self.key],
snippet="\n".join(snippet_lines) if snippet_lines else None,
description="\n".join(description_lines),
)
DEFAULT_SECTIONS = [
ParamSection("Parameters", "param"),
ParamSection("Params", "param"),
ParamSection("Arguments", "param"),
ParamSection("Args", "param"),
ParamSection("Other Parameters", "other_param"),
ParamSection("Other Params", "other_param"),
ParamSection("Other Arguments", "other_param"),
ParamSection("Other Args", "other_param"),
ParamSection("Receives", "receives"),
ParamSection("Receive", "receives"),
RaisesSection("Raises", "raises"),
RaisesSection("Raise", "raises"),
RaisesSection("Warns", "warns"),
RaisesSection("Warn", "warns"),
ParamSection("Attributes", "attribute"),
ParamSection("Attribute", "attribute"),
ReturnsSection("Returns", "returns"),
ReturnsSection("Return", "returns"),
YieldsSection("Yields", "yields"),
YieldsSection("Yield", "yields"),
ExamplesSection("Examples", "examples"),
ExamplesSection("Example", "examples"),
Section("Warnings", "warnings"),
Section("Warning", "warnings"),
Section("See Also", "see_also"),
Section("Related", "see_also"),
Section("Notes", "notes"),
Section("Note", "notes"),
Section("References", "references"),
Section("Reference", "references"),
DeprecationSection("deprecated", "deprecation"),
]
class NumpydocParser:
"""Parser for numpydoc-style docstrings."""
def __init__(self, sections: T.Optional[T.Dict[str, Section]] = None):
"""Setup sections.
:param sections: Recognized sections or None to defaults.
"""
sections = sections or DEFAULT_SECTIONS
self.sections = {s.title: s for s in sections}
self._setup()
def _setup(self):
self.titles_re = re.compile(
r"|".join(s.title_pattern for s in self.sections.values()),
flags=re.M,
)
def add_section(self, section: Section):
"""Add or replace a section.
:param section: The new section.
"""
self.sections[section.title] = section
self._setup()
def parse(self, text: T.Optional[str]) -> Docstring:
"""Parse the numpy-style docstring into its components.
:returns: parsed docstring
"""
ret = Docstring(style=DocstringStyle.NUMPYDOC)
if not text:
return ret
# Clean according to PEP-0257
text = inspect.cleandoc(text)
# Find first title and split on its position
match = self.titles_re.search(text)
if match:
desc_chunk = text[: match.start()]
meta_chunk = text[match.start() :]
else:
desc_chunk = text
meta_chunk = ""
# Break description into short and long parts
parts = desc_chunk.split("\n", 1)
ret.short_description = parts[0] or None
if len(parts) > 1:
long_desc_chunk = parts[1] or ""
ret.blank_after_short_description = long_desc_chunk.startswith(
"\n"
)
ret.blank_after_long_description = long_desc_chunk.endswith("\n\n")
ret.long_description = long_desc_chunk.strip() or None
for match, nextmatch in _pairwise(self.titles_re.finditer(meta_chunk)):
title = next(g for g in match.groups() if g is not None)
factory = self.sections[title]
# section chunk starts after the header,
# ends at the start of the next header
start = match.end()
end = nextmatch.start() if nextmatch is not None else None
ret.meta.extend(factory.parse(meta_chunk[start:end]))
return ret
def parse(text: T.Optional[str]) -> Docstring:
"""Parse the numpy-style docstring into its components.
:returns: parsed docstring
"""
return NumpydocParser().parse(text)
def compose(
# pylint: disable=W0613
docstring: Docstring,
rendering_style: RenderingStyle = RenderingStyle.COMPACT,
indent: str = " ",
) -> str:
"""Render a parsed docstring into docstring text.
:param docstring: parsed docstring representation
:param rendering_style: the style to render docstrings
:param indent: the characters used as indentation in the docstring string
:returns: docstring text
"""
def process_one(
one: T.Union[DocstringParam, DocstringReturns, DocstringRaises],
):
if isinstance(one, DocstringParam):
head = one.arg_name
elif isinstance(one, DocstringReturns):
head = one.return_name
else:
head = None
if one.type_name and head:
head += f" : {one.type_name}"
elif one.type_name:
head = one.type_name
elif not head:
head = ""
# If this is a parameter, check if it's optional.
# If it is and there's a not-None default, include that in the type
# declaration, otherwise just mark it as optional.
if isinstance(one, DocstringParam):
if one.default not in [None, "None"]:
head += f", default={one.default}"
elif one.is_optional or one.default == "None":
head += ", optional"
if one.description:
body = f"\n{indent}".join([head] + one.description.splitlines())
parts.append(body)
else:
parts.append(head)
def process_sect(name: str, args: T.List[T.Any]):
if args:
parts.append("")
parts.append(name)
parts.append("-" * len(parts[-1]))
for arg in args:
process_one(arg)
parts: T.List[str] = []
if docstring.short_description:
parts.append(docstring.short_description)
if docstring.blank_after_short_description:
parts.append("")
if docstring.deprecation:
first = ".. deprecated::"
if docstring.deprecation.version:
first += f" {docstring.deprecation.version}"
if docstring.deprecation.description:
rest = docstring.deprecation.description.splitlines()
else:
rest = []
sep = f"\n{indent}"
parts.append(sep.join([first] + rest))
if docstring.long_description:
parts.append(docstring.long_description)
if docstring.blank_after_long_description:
parts.append("")
process_sect(
"Parameters",
[item for item in docstring.params or [] if item.args[0] == "param"],
)
process_sect(
"Attributes",
[
item
for item in docstring.params or []
if item.args[0] == "attribute"
],
)
process_sect(
"Returns",
[
item
for item in docstring.many_returns or []
if not item.is_generator
],
)
process_sect(
"Yields",
[item for item in docstring.many_returns or [] if item.is_generator],
)
if docstring.returns and not docstring.many_returns:
ret = docstring.returns
parts.append("Yields" if ret else "Returns")
parts.append("-" * len(parts[-1]))
process_one(ret)
process_sect(
"Receives",
[
item
for item in docstring.params or []
if item.args[0] == "receives"
],
)
process_sect(
"Other Parameters",
[
item
for item in docstring.params or []
if item.args[0] == "other_param"
],
)
process_sect(
"Raises",
[item for item in docstring.raises or [] if item.args[0] == "raises"],
)
process_sect(
"Warns",
[item for item in docstring.raises or [] if item.args[0] == "warns"],
)
if len(docstring.examples) > 0:
parts.append("")
parts.append("Examples")
parts.append("--------")
for example in docstring.examples:
if example.snippet:
parts.append(example.snippet)
if example.description:
parts.append(example.description)
for meta in docstring.meta:
if isinstance(
meta,
(
DocstringDeprecated,
DocstringParam,
DocstringReturns,
DocstringRaises,
DocstringExample,
),
):
continue # Already handled
parts.append("")
parts.append(meta.args[0].replace("_", "").title())
parts.append("-" * len(meta.args[0]))
if meta.description:
parts.append(meta.description)
return "\n".join(parts)

View File

@@ -0,0 +1,100 @@
"""The main parsing routine."""
import inspect
import typing as T
from docstring_parser import epydoc, google, numpydoc, rest
from docstring_parser.attrdoc import add_attribute_docstrings
from docstring_parser.common import (
Docstring,
DocstringStyle,
ParseError,
RenderingStyle,
)
_STYLE_MAP = {
DocstringStyle.REST: rest,
DocstringStyle.GOOGLE: google,
DocstringStyle.NUMPYDOC: numpydoc,
DocstringStyle.EPYDOC: epydoc,
}
def parse(
text: T.Optional[str], style: DocstringStyle = DocstringStyle.AUTO
) -> Docstring:
"""Parse the docstring into its components.
:param text: docstring text to parse
:param style: docstring style
:returns: parsed docstring representation
"""
if style != DocstringStyle.AUTO:
return _STYLE_MAP[style].parse(text)
exc: T.Optional[Exception] = None
rets = []
for module in _STYLE_MAP.values():
try:
ret = module.parse(text)
except ParseError as ex:
exc = ex
else:
rets.append(ret)
if not rets:
raise exc
return sorted(rets, key=lambda d: len(d.meta), reverse=True)[0]
def parse_from_object(
obj: T.Any,
style: DocstringStyle = DocstringStyle.AUTO,
) -> Docstring:
"""Parse the object's docstring(s) into its components.
The object can be anything that has a ``__doc__`` attribute. In contrast to
the ``parse`` function, ``parse_from_object`` is able to parse attribute
docstrings which are defined in the source code instead of ``__doc__``.
Currently only attribute docstrings defined at class and module levels are
supported. Attribute docstrings defined in ``__init__`` methods are not
supported.
When given a class, only the attribute docstrings of that class are parsed,
not its inherited classes. This is a design decision. Separate calls to
this function should be performed to get attribute docstrings of parent
classes.
:param obj: object from which to parse the docstring(s)
:param style: docstring style
:returns: parsed docstring representation
"""
docstring = parse(obj.__doc__, style=style)
if inspect.isclass(obj) or inspect.ismodule(obj):
add_attribute_docstrings(obj, docstring)
return docstring
def compose(
docstring: Docstring,
style: DocstringStyle = DocstringStyle.AUTO,
rendering_style: RenderingStyle = RenderingStyle.COMPACT,
indent: str = " ",
) -> str:
"""Render a parsed docstring into docstring text.
:param docstring: parsed docstring representation
:param style: docstring style to render
:param indent: the characters used as indentation in the docstring string
:returns: docstring text
"""
module = _STYLE_MAP[
docstring.style if style == DocstringStyle.AUTO else style
]
return module.compose(
docstring, rendering_style=rendering_style, indent=indent
)

View File

@@ -0,0 +1 @@
# Marker file for PEP 561.

View File

@@ -0,0 +1,259 @@
"""ReST-style docstring parsing."""
import inspect
import re
import typing as T
from .common import (
DEPRECATION_KEYWORDS,
PARAM_KEYWORDS,
RAISES_KEYWORDS,
RETURNS_KEYWORDS,
YIELDS_KEYWORDS,
Docstring,
DocstringDeprecated,
DocstringMeta,
DocstringParam,
DocstringRaises,
DocstringReturns,
DocstringStyle,
ParseError,
RenderingStyle,
)
def _build_meta(args: T.List[str], desc: str) -> DocstringMeta:
key = args[0]
if key in PARAM_KEYWORDS:
if len(args) == 3:
key, type_name, arg_name = args
if type_name.endswith("?"):
is_optional = True
type_name = type_name[:-1]
else:
is_optional = False
elif len(args) == 2:
key, arg_name = args
type_name = None
is_optional = None
else:
raise ParseError(
f"Expected one or two arguments for a {key} keyword."
)
match = re.match(r".*defaults to (.+)", desc, flags=re.DOTALL)
default = match.group(1).rstrip(".") if match else None
return DocstringParam(
args=args,
description=desc,
arg_name=arg_name,
type_name=type_name,
is_optional=is_optional,
default=default,
)
if key in RETURNS_KEYWORDS | YIELDS_KEYWORDS:
if len(args) == 2:
type_name = args[1]
elif len(args) == 1:
type_name = None
else:
raise ParseError(
f"Expected one or no arguments for a {key} keyword."
)
return DocstringReturns(
args=args,
description=desc,
type_name=type_name,
is_generator=key in YIELDS_KEYWORDS,
)
if key in DEPRECATION_KEYWORDS:
match = re.search(
r"^(?P<version>v?((?:\d+)(?:\.[0-9a-z\.]+))) (?P<desc>.+)",
desc,
flags=re.I,
)
return DocstringDeprecated(
args=args,
version=match.group("version") if match else None,
description=match.group("desc") if match else desc,
)
if key in RAISES_KEYWORDS:
if len(args) == 2:
type_name = args[1]
elif len(args) == 1:
type_name = None
else:
raise ParseError(
f"Expected one or no arguments for a {key} keyword."
)
return DocstringRaises(
args=args, description=desc, type_name=type_name
)
return DocstringMeta(args=args, description=desc)
def parse(text: T.Optional[str]) -> Docstring:
"""Parse the ReST-style docstring into its components.
:returns: parsed docstring
"""
ret = Docstring(style=DocstringStyle.REST)
if not text:
return ret
text = inspect.cleandoc(text)
match = re.search("^:", text, flags=re.M)
if match:
desc_chunk = text[: match.start()]
meta_chunk = text[match.start() :]
else:
desc_chunk = text
meta_chunk = ""
parts = desc_chunk.split("\n", 1)
ret.short_description = parts[0] or None
if len(parts) > 1:
long_desc_chunk = parts[1] or ""
ret.blank_after_short_description = long_desc_chunk.startswith("\n")
ret.blank_after_long_description = long_desc_chunk.endswith("\n\n")
ret.long_description = long_desc_chunk.strip() or None
types = {}
rtypes = {}
for match in re.finditer(
r"(^:.*?)(?=^:|\Z)", meta_chunk, flags=re.S | re.M
):
chunk = match.group(0)
if not chunk:
continue
try:
args_chunk, desc_chunk = chunk.lstrip(":").split(":", 1)
except ValueError as ex:
raise ParseError(
f'Error parsing meta information near "{chunk}".'
) from ex
args = args_chunk.split()
desc = desc_chunk.strip()
if "\n" in desc:
first_line, rest = desc.split("\n", 1)
desc = first_line + "\n" + inspect.cleandoc(rest)
# Add special handling for :type a: typename
if len(args) == 2 and args[0] == "type":
types[args[1]] = desc
elif len(args) in [1, 2] and args[0] == "rtype":
rtypes[None if len(args) == 1 else args[1]] = desc
else:
ret.meta.append(_build_meta(args, desc))
for meta in ret.meta:
if isinstance(meta, DocstringParam):
meta.type_name = meta.type_name or types.get(meta.arg_name)
elif isinstance(meta, DocstringReturns):
meta.type_name = meta.type_name or rtypes.get(meta.return_name)
if not any(isinstance(m, DocstringReturns) for m in ret.meta) and rtypes:
for return_name, type_name in rtypes.items():
ret.meta.append(
DocstringReturns(
args=[],
type_name=type_name,
description=None,
is_generator=False,
return_name=return_name,
)
)
return ret
def compose(
docstring: Docstring,
rendering_style: RenderingStyle = RenderingStyle.COMPACT,
indent: str = " ",
) -> str:
"""Render a parsed docstring into docstring text.
:param docstring: parsed docstring representation
:param rendering_style: the style to render docstrings
:param indent: the characters used as indentation in the docstring string
:returns: docstring text
"""
def process_desc(desc: T.Optional[str]) -> str:
if not desc:
return ""
if rendering_style == RenderingStyle.CLEAN:
(first, *rest) = desc.splitlines()
return "\n".join([" " + first] + [indent + line for line in rest])
if rendering_style == RenderingStyle.EXPANDED:
(first, *rest) = desc.splitlines()
return "\n".join(
["\n" + indent + first] + [indent + line for line in rest]
)
return " " + desc
parts: T.List[str] = []
if docstring.short_description:
parts.append(docstring.short_description)
if docstring.blank_after_short_description:
parts.append("")
if docstring.long_description:
parts.append(docstring.long_description)
if docstring.blank_after_long_description:
parts.append("")
for meta in docstring.meta:
if isinstance(meta, DocstringParam):
if meta.type_name:
type_text = (
f" {meta.type_name}? "
if meta.is_optional
else f" {meta.type_name} "
)
else:
type_text = " "
if rendering_style == RenderingStyle.EXPANDED:
text = f":param {meta.arg_name}:"
text += process_desc(meta.description)
parts.append(text)
if type_text[:-1]:
parts.append(f":type {meta.arg_name}:{type_text[:-1]}")
else:
text = f":param{type_text}{meta.arg_name}:"
text += process_desc(meta.description)
parts.append(text)
elif isinstance(meta, DocstringReturns):
type_text = f" {meta.type_name}" if meta.type_name else ""
key = "yields" if meta.is_generator else "returns"
if rendering_style == RenderingStyle.EXPANDED:
if meta.description:
text = f":{key}:"
text += process_desc(meta.description)
parts.append(text)
if type_text:
parts.append(f":rtype:{type_text}")
else:
text = f":{key}{type_text}:"
text += process_desc(meta.description)
parts.append(text)
elif isinstance(meta, DocstringRaises):
type_text = f" {meta.type_name} " if meta.type_name else ""
text = f":raises{type_text}:" + process_desc(meta.description)
parts.append(text)
else:
text = f':{" ".join(meta.args)}:' + process_desc(meta.description)
parts.append(text)
return "\n".join(parts)

View File

@@ -0,0 +1,145 @@
"""Utility functions for working with docstrings."""
import typing as T
from collections import ChainMap
from inspect import Signature
from itertools import chain
from .common import (
DocstringMeta,
DocstringParam,
DocstringReturns,
DocstringStyle,
RenderingStyle,
)
from .parser import compose, parse
_Func = T.Callable[..., T.Any]
assert DocstringReturns # used in docstring
def combine_docstrings(
*others: _Func,
exclude: T.Iterable[T.Type[DocstringMeta]] = (),
style: DocstringStyle = DocstringStyle.AUTO,
rendering_style: RenderingStyle = RenderingStyle.COMPACT,
) -> _Func:
"""A function decorator that parses the docstrings from `others`,
programmatically combines them with the parsed docstring of the decorated
function, and replaces the docstring of the decorated function with the
composed result. Only parameters that are part of the decorated functions
signature are included in the combined docstring. When multiple sources for
a parameter or docstring metadata exists then the decorator will first
default to the wrapped function's value (when available) and otherwise use
the rightmost definition from ``others``.
The following example illustrates its usage:
>>> def fun1(a, b, c, d):
... '''short_description: fun1
...
... :param a: fun1
... :param b: fun1
... :return: fun1
... '''
>>> def fun2(b, c, d, e):
... '''short_description: fun2
...
... long_description: fun2
...
... :param b: fun2
... :param c: fun2
... :param e: fun2
... '''
>>> @combine_docstrings(fun1, fun2)
>>> def decorated(a, b, c, d, e, f):
... '''
... :param e: decorated
... :param f: decorated
... '''
>>> print(decorated.__doc__)
short_description: fun2
<BLANKLINE>
long_description: fun2
<BLANKLINE>
:param a: fun1
:param b: fun1
:param c: fun2
:param e: fun2
:param f: decorated
:returns: fun1
>>> @combine_docstrings(fun1, fun2, exclude=[DocstringReturns])
>>> def decorated(a, b, c, d, e, f): pass
>>> print(decorated.__doc__)
short_description: fun2
<BLANKLINE>
long_description: fun2
<BLANKLINE>
:param a: fun1
:param b: fun1
:param c: fun2
:param e: fun2
:param others: callables from which to parse docstrings.
:param exclude: an iterable of ``DocstringMeta`` subclasses to exclude when
combining docstrings.
:param style: style composed docstring. The default will infer the style
from the decorated function.
:param rendering_style: The rendering style used to compose a docstring.
:return: the decorated function with a modified docstring.
"""
def wrapper(func: _Func) -> _Func:
sig = Signature.from_callable(func)
comb_doc = parse(func.__doc__ or "")
docs = [parse(other.__doc__ or "") for other in others] + [comb_doc]
params = dict(
ChainMap(
*(
{param.arg_name: param for param in doc.params}
for doc in docs
)
)
)
for doc in reversed(docs):
if not doc.short_description:
continue
comb_doc.short_description = doc.short_description
comb_doc.blank_after_short_description = (
doc.blank_after_short_description
)
break
for doc in reversed(docs):
if not doc.long_description:
continue
comb_doc.long_description = doc.long_description
comb_doc.blank_after_long_description = (
doc.blank_after_long_description
)
break
combined = {}
for doc in docs:
metas = {}
for meta in doc.meta:
meta_type = type(meta)
if meta_type in exclude:
continue
metas.setdefault(meta_type, []).append(meta)
for meta_type, meta in metas.items():
combined[meta_type] = meta
combined[DocstringParam] = [
params[name] for name in sig.parameters if name in params
]
comb_doc.meta = list(chain(*combined.values()))
func.__doc__ = compose(
comb_doc, style=style, rendering_style=rendering_style
)
return func
return wrapper