Tools / Modules /_docstrings.py
Nymbo's picture
Update Modules/_docstrings.py
858a56f verified
raw
history blame
7.27 kB
from __future__ import annotations
import ast
import inspect
import sys
import types
from typing import Any, Annotated, get_args, get_origin, get_type_hints, Union
def _typename(tp: Any) -> str:
"""Return a readable type name from a type or annotation."""
try:
# Unwrap Optional[T] -> T
origin = get_origin(tp)
if origin is Union or (sys.version_info >= (3, 10) and origin is types.UnionType):
args = [a for a in get_args(tp) if a is not type(None)]
if len(args) == 1:
return _typename(args[0])
if hasattr(tp, "__name__"):
return tp.__name__ # e.g. int, str
if getattr(tp, "__module__", None) and getattr(tp, "__qualname__", None):
return f"{tp.__module__}.{tp.__qualname__}"
return str(tp).replace("typing.", "")
except Exception:
return str(tp)
def _extract_base_and_meta(annotation: Any) -> tuple[Any, str | None]:
"""Given an annotation, return (base_type, first string metadata) if Annotated, else (annotation, None)."""
try:
if get_origin(annotation) is Annotated:
args = get_args(annotation)
base = args[0] if args else annotation
# Grab the first string metadata if present
for meta in args[1:]:
if isinstance(meta, str):
return base, meta
return base, None
return annotation, None
except Exception:
return annotation, None
def _parse_annotated_string(annot_str: str) -> tuple[str, str | None]:
"""Fallback: parse 'Annotated[Type, "desc"]' string using AST."""
try:
expr = ast.parse(annot_str, mode='eval').body
if isinstance(expr, ast.Subscript):
val = expr.value
is_annotated = False
if isinstance(val, ast.Name) and val.id == 'Annotated':
is_annotated = True
elif isinstance(val, ast.Attribute) and val.attr == 'Annotated':
is_annotated = True
if is_annotated:
sl = expr.slice
# In 3.9+, slice is the node. In <3.9, it might be Index/ExtSlice.
if isinstance(sl, ast.Tuple):
elts = sl.elts
if len(elts) >= 2:
# elts[0] is type, elts[1] is metadata
meta_node = elts[1]
desc = None
if isinstance(meta_node, ast.Constant) and isinstance(meta_node.value, str):
desc = meta_node.value
elif isinstance(meta_node, ast.Str):
desc = meta_node.s
if desc:
if hasattr(ast, 'unparse'):
type_str = ast.unparse(elts[0])
else:
type_str = "Any"
return type_str, desc
except Exception:
pass
return annot_str, None
def autodoc(summary: str | None = None, returns: str | None = None, *, force: bool = False):
"""
Decorator that auto-generates a concise Google-style docstring from a function's
type hints and Annotated metadata. Useful for Gradio MCP where docstrings are
used for tool descriptions and parameter docs.
Args:
summary: Optional one-line summary for the function. If not provided,
will generate a simple sentence from the function name.
returns: Optional return value description. If not provided, only the
return type will be listed (if available).
force: When True, overwrite an existing docstring. Default False.
Returns:
The original function with its __doc__ populated (unless skipped).
"""
def decorator(func):
# Skip if docstring already present and not forcing
if not force and func.__doc__ and func.__doc__.strip():
return func
try:
# include_extras=True to retain Annotated metadata
hints = get_type_hints(func, include_extras=True, globalns=getattr(func, "__globals__", None))
except Exception:
# Fallback: try to evaluate annotations manually if they are strings
hints = {}
sig = inspect.signature(func)
for name, param in sig.parameters.items():
if isinstance(param.annotation, str):
try:
# Ensure typing is available in eval context
globs = getattr(func, "__globals__", {}).copy()
import typing
globs['typing'] = typing
for t in ['Annotated', 'Literal', 'Optional', 'Union', 'List', 'Dict', 'Any']:
if t not in globs:
globs[t] = getattr(typing, t)
val = eval(param.annotation, globs)
hints[name] = val
except Exception:
pass
sig = inspect.signature(func)
lines: list[str] = []
# Summary line
if summary and summary.strip():
lines.append(summary.strip())
else:
pretty = func.__name__.replace("_", " ").strip().capitalize()
if not pretty.endswith("."):
pretty += "."
lines.append(pretty)
# Args section
if sig.parameters:
lines.append("")
lines.append("Args:")
for name, param in sig.parameters.items():
if name == "self":
continue
annot = hints.get(name, param.annotation)
base, meta = _extract_base_and_meta(annot)
# If meta is missing and annot is a string, try AST fallback
if meta is None and isinstance(annot, str):
base_str, meta_str = _parse_annotated_string(annot)
if meta_str:
base = base_str
meta = meta_str
tname = _typename(base) if base is not inspect._empty else None
desc = meta or ""
if tname and tname != str(inspect._empty):
lines.append(f" {name} ({tname}): {desc}".rstrip())
else:
lines.append(f" {name}: {desc}".rstrip())
# Returns section
ret_hint = hints.get("return", sig.return_annotation)
if returns or (ret_hint and ret_hint is not inspect.Signature.empty):
lines.append("")
lines.append("Returns:")
if returns:
lines.append(f" {returns}")
else:
base, meta = _extract_base_and_meta(ret_hint)
rtype = _typename(base)
if meta:
lines.append(f" {rtype}: {meta}")
else:
lines.append(f" {rtype}")
func.__doc__ = "\n".join(lines).strip() + "\n"
return func
return decorator
__all__ = ["autodoc"]