Loading...
No commits yet
Not committed History
Blame
introspect.py • 10.2 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Timestamp: 2026-01-30
# File: src/scitex_writer/_cli/introspect.py

"""Introspection CLI commands for scitex-writer (figrecipe-compatible format)."""

import argparse
import importlib
import inspect
import sys

# Color mapping for types (matching figrecipe)
TYPE_COLORS = {"M": "blue", "C": "magenta", "F": "green", "V": "cyan"}

# ANSI color codes
ANSI = {
    "blue": "\033[34m",
    "magenta": "\033[35m",
    "green": "\033[32m",
    "cyan": "\033[36m",
    "white": "\033[37m",
    "yellow": "\033[33m",
    "reset": "\033[0m",
    "bold": "\033[1m",
}


def _style(text: str, fg: str = None, bold: bool = False) -> str:
    """Apply ANSI styling to text."""
    if not sys.stdout.isatty():
        return text
    prefix = ""
    if bold:
        prefix += ANSI["bold"]
    if fg and fg in ANSI:
        prefix += ANSI[fg]
    if prefix:
        return f"{prefix}{text}{ANSI['reset']}"
    return text


def _simplify_type(ann) -> str:
    """Simplify type annotation to base type name like figrecipe."""
    import types
    import typing

    # Handle Python 3.10+ UnionType (str | None)
    if isinstance(ann, types.UnionType):
        args = typing.get_args(ann)
        non_none = [a for a in args if a is not type(None)]
        if len(non_none) == 1 and type(None) in args:
            return "Optional"
        return "Union"

    # Get origin for generic types (Optional, Union, List, Dict, etc.)
    origin = typing.get_origin(ann)
    if origin is not None:
        # For Union types (including Optional which is Union[X, None])
        if origin is typing.Union:
            args = typing.get_args(ann)
            # Check if it's Optional (Union with None)
            non_none = [a for a in args if a is not type(None)]
            if len(non_none) == 1 and type(None) in args:
                return "Optional"
            return "Union"
        # For other generic types, return the origin name
        return origin.__name__ if hasattr(origin, "__name__") else str(origin)

    # For simple types
    if hasattr(ann, "__name__"):
        return ann.__name__

    # Fallback: string representation cleaned up
    type_str = str(ann)
    type_str = type_str.replace("typing.", "")
    # Further simplify: extract just the base type
    if "[" in type_str:
        type_str = type_str.split("[")[0]
    return type_str


def _format_python_signature(func, multiline: bool = True, indent: str = "  ") -> tuple:
    """Format Python function signature with colors matching figrecipe.

    Returns (name_colored, signature_colored)
    """
    try:
        sig = inspect.signature(func)
    except (ValueError, TypeError):
        return _style(func.__name__, "green", bold=True), ""

    params = []
    for name, param in sig.parameters.items():
        # Get type annotation
        if param.annotation != inspect.Parameter.empty:
            type_str = _simplify_type(param.annotation)
        else:
            type_str = None

        # Get default value
        if param.default != inspect.Parameter.empty:
            default = param.default
            def_str = repr(default) if len(repr(default)) < 20 else "..."
            if type_str:
                p = f"{_style(name, 'white', bold=True)}: {_style(type_str, 'cyan')} = {_style(def_str, 'yellow')}"
            else:
                p = f"{_style(name, 'white', bold=True)} = {_style(def_str, 'yellow')}"
        else:
            if type_str:
                p = f"{_style(name, 'white', bold=True)}: {_style(type_str, 'cyan')}"
            else:
                p = _style(name, "white", bold=True)
        params.append(p)

    # Return type
    ret_str = ""
    if sig.return_annotation != inspect.Parameter.empty:
        ret = sig.return_annotation
        ret_name = ret.__name__ if hasattr(ret, "__name__") else str(ret)
        ret_name = ret_name.replace("typing.", "")
        ret_str = f" -> {_style(ret_name, 'magenta')}"

    name_s = _style(func.__name__, "green", bold=True)

    if multiline and len(params) > 2:
        param_indent = indent + "    "
        params_str = ",\n".join(f"{param_indent}{p}" for p in params)
        sig_s = f"(\n{params_str}\n{indent}){ret_str}"
    else:
        sig_s = f"({', '.join(params)}){ret_str}"

    return name_s, sig_s


def _get_api_tree(module, max_depth: int = 5, docstring: bool = False) -> list[dict]:
    """Get API tree for a module with types and signatures.

    Returns list of dicts with: Name, Type, Depth, Docstring (optional)
    """
    results = []

    def _visit(obj, name: str, depth: int, visited: set):
        if depth > max_depth:
            return
        obj_id = id(obj)
        if obj_id in visited:
            return
        visited.add(obj_id)

        # Determine type
        if inspect.ismodule(obj):
            obj_type = "M"
        elif inspect.isclass(obj):
            obj_type = "C"
        elif callable(obj):
            obj_type = "F"
        else:
            obj_type = "V"

        entry = {"Name": name, "Type": obj_type, "Depth": depth}
        if docstring:
            entry["Docstring"] = inspect.getdoc(obj) or ""
        results.append(entry)

        # Recurse into modules
        if inspect.ismodule(obj) and depth < max_depth:
            if hasattr(obj, "__all__"):
                members = [(n, getattr(obj, n, None)) for n in obj.__all__]
            else:
                members = [
                    (n, v) for n, v in inspect.getmembers(obj) if not n.startswith("_")
                ]
            for member_name, member_obj in members:
                if member_obj is not None:
                    _visit(member_obj, f"{name}.{member_name}", depth + 1, visited)

    _visit(module, module.__name__.split(".")[-1], 0, set())
    return results


def cmd_api(args: argparse.Namespace) -> int:
    """List API tree of a Python module."""
    dotted_path = args.dotted_path.replace("-", "_")

    try:
        module = importlib.import_module(dotted_path)
    except ImportError as e:
        print(f"Error importing {dotted_path}: {e}", file=sys.stderr)
        return 1

    df = _get_api_tree(module, max_depth=args.max_depth, docstring=(args.verbose >= 1))

    if args.json:
        import json

        print(json.dumps(df, indent=2))
        return 0

    print(_style(f"API tree of {dotted_path} ({len(df)} items):", fg="cyan"))
    legend = " ".join(
        _style(f"[{t}]={n}", fg=TYPE_COLORS[t])
        for t, n in [
            ("M", "Module"),
            ("C", "Class"),
            ("F", "Function"),
            ("V", "Variable"),
        ]
    )
    print(f"Legend: {legend}")

    for row in df:
        indent = "  " * row["Depth"]
        t = row["Type"]
        type_s = _style(f"[{t}]", fg=TYPE_COLORS.get(t, "yellow"))
        name = row["Name"].split(".")[-1]

        if t == "F":
            try:
                # Get the actual function
                parts = row["Name"].split(".")
                obj = module
                for part in parts[1:]:  # Skip module name
                    obj = getattr(obj, part, None)
                    if obj is None:
                        break
                if obj and callable(obj):
                    name_s, sig_s = _format_python_signature(obj, indent=indent)
                    print(f"{indent}{type_s} {name_s}{sig_s}")
                else:
                    name_s = _style(name, "green", bold=True)
                    print(f"{indent}{type_s} {name_s}")
            except Exception:
                name_s = _style(name, "green", bold=True)
                print(f"{indent}{type_s} {name_s}")
        else:
            name_s = _style(name, fg=TYPE_COLORS.get(t, "white"), bold=True)
            print(f"{indent}{type_s} {name_s}")

        if args.verbose >= 1 and row.get("Docstring"):
            if args.verbose == 1:
                doc = row["Docstring"].split("\n")[0][:60]
                print(f"{indent}    - {doc}")
            else:
                for ln in row["Docstring"].split("\n"):
                    print(f"{indent}    {ln}")

    return 0


def cmd_list_python_apis(args: argparse.Namespace) -> int:
    """List Python APIs (alias for introspect api scitex_writer)."""
    args.dotted_path = "scitex_writer"
    return cmd_api(args)


def register_parser(subparsers) -> argparse.ArgumentParser:
    """Register introspect subcommand parser."""
    intro_help = """Python package introspection utilities.

Quick start:
  scitex-writer introspect api scitex_writer       # Full API tree
  scitex-writer introspect api scitex_writer -v    # With docstrings
  scitex-writer introspect api scitex_writer --json  # JSON output
"""
    intro_parser = subparsers.add_parser(
        "introspect",
        help="Python package introspection",
        description=intro_help,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    intro_sub = intro_parser.add_subparsers(dest="introspect_command", title="Commands")

    api_parser = intro_sub.add_parser("api", help="List API tree of a module")
    api_parser.add_argument(
        "dotted_path", help="Python dotted path (e.g., scitex_writer)"
    )
    api_parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        help="Verbosity: -v +doc, -vv full doc",
    )
    api_parser.add_argument(
        "-d",
        "--max-depth",
        type=int,
        default=5,
        help="Max recursion depth (default: 5)",
    )
    api_parser.add_argument(
        "--json",
        action="store_true",
        default=False,
        help="Output as JSON",
    )
    api_parser.set_defaults(func=cmd_api)

    return intro_parser


def register_list_python_apis(parent_parser) -> None:
    """Register list-python-apis command on a parent parser."""
    lst_parser = parent_parser.add_parser(
        "list-python-apis",
        help="List Python APIs (alias for: scitex-writer introspect api scitex_writer)",
    )
    lst_parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        help="Verbosity: -v +doc, -vv full doc",
    )
    lst_parser.add_argument(
        "-d", "--max-depth", type=int, default=5, help="Max recursion depth"
    )
    lst_parser.add_argument(
        "--json",
        action="store_true",
        default=False,
        help="Output as JSON",
    )
    lst_parser.set_defaults(func=cmd_list_python_apis)


# EOF