#!/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