File size: 5,457 Bytes
e48cd48
 
 
55ab3a6
07e140c
e48cd48
 
 
 
 
 
 
 
 
 
 
 
 
 
55ab3a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e48cd48
 
 
55ab3a6
 
 
 
 
 
 
e48cd48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858a56f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
from __future__ import annotations

import inspect
import re
from typing import Any, Annotated, get_args, get_origin, get_type_hints


def _typename(tp: Any) -> str:
    """Return a readable type name from a type or annotation."""
    try:
        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 _parse_string_annotation(annot_str: str) -> tuple[str | None, str | None]:
    """
    Parse a string annotation like "Annotated[Optional[str], 'description']"
    and extract the base type name and the description metadata.
    
    Returns (base_type_name, description) or (None, None) if parsing fails.
    """
    if not isinstance(annot_str, str):
        return None, None
    
    # Match Annotated[..., 'description'] or Annotated[..., "description"]
    # Pattern: Annotated[<base_type>, '<description>'] or with double quotes
    match = re.match(
        r"^Annotated\[(.+?),\s*['\"](.+?)['\"]\s*\]$",
        annot_str.strip(),
        re.DOTALL,
    )
    if match:
        base_type_str = match.group(1).strip()
        description = match.group(2)
        # Simplify Optional[X] -> just the base type for display
        opt_match = re.match(r"^Optional\[(.+)\]$", base_type_str)
        if opt_match:
            base_type_str = opt_match.group(1).strip()
        return base_type_str, description
    
    return None, None


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:
        # Handle string annotations from PEP 563 (__future__.annotations)
        if isinstance(annotation, str):
            base_str, meta = _parse_string_annotation(annotation)
            if meta:
                return base_str or annotation, meta
            return annotation, None
        
        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 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:
            hints = {}

        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)
                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"]