Skip to content

lythonic.compose

Build typed callable compositions from annotated functions.

Compose: Build typed callable compositions from annotated functions.

This module provides introspection and composition primitives for building higher-level structures (CLIs, pipelines, workflows) from type-annotated callables.

Core Concepts

  • Method: Wrapper around a callable with signature introspection
  • MethodDict: Dictionary of methods indexed by name
  • ArgInfo: Metadata about function arguments

CLI Support

For building command-line interfaces, see lythonic.compose.cli:

from lythonic.compose.cli import ActionTree, Main, RunContext

Method

Wrapper around a callable that provides introspection of its arguments.

Lazily loads the callable via GlobalRef and extracts argument metadata from the function signature. Supports both regular functions and Pydantic BaseModel classes (using their __init__ signature).

Source code in src/lythonic/compose/__init__.py
class Method:
    """
    Wrapper around a callable that provides introspection of its arguments.

    Lazily loads the callable via GlobalRef and extracts argument metadata
    from the function signature. Supports both regular functions and Pydantic
    BaseModel classes (using their `__init__` signature).
    """

    gref: GlobalRef
    _o: Callable[..., Any] | None
    _args: list[ArgInfo] | None
    _args_by_name: dict[str, ArgInfo] | None
    _return_annotation: Any | None

    def __init__(self, o: Callable[..., Any] | GlobalRef):
        if isinstance(o, GlobalRef):
            self.gref = o
            self._o = None
        else:
            self.gref = GlobalRef(o)
            assert isinstance(o, Callable), "method instance must be a callable"
            self._o = o
        self._args = None
        self._args_by_name = None
        self._return_annotation = None

    def _update_from_signature(self):
        o = self.o
        sig = inspect.signature(o)
        self._args = [ArgInfo.from_param(param, origin=o) for param in sig.parameters.values()]
        self._args_by_name = {arg.name: arg for arg in self._args}
        self._return_annotation = sig.return_annotation

    @property
    def o(self) -> Callable[..., Any]:
        if self._o is None:
            self._o = self.gref.get_instance()
        assert self._o is not None
        return self._o

    @property
    def args(self) -> list[ArgInfo]:
        if self._args is None:
            self._update_from_signature()
        assert self._args is not None
        return self._args

    @property
    def args_by_name(self) -> dict[str, ArgInfo]:
        if self._args_by_name is None:
            self._update_from_signature()
        assert self._args_by_name is not None
        return self._args_by_name

    @property
    def return_annotation(self) -> Any | None:
        if self._args is None:
            self._update_from_signature()
        return self._return_annotation

    @property
    def name(self):
        return self.gref.name

    @property
    def doc(self):
        return self.o.__doc__

    def __call__(self, *args: Any, **kwargs: Any):
        return self.o(*args, **kwargs)

MethodDict

Bases: Generic[T], dict[str, T]

Dictionary mapping lowercased method names to Method instances.

Use add() to register a callable, or wrap() as a decorator.

Source code in src/lythonic/compose/__init__.py
class MethodDict(Generic[T], dict[str, T]):
    """
    Dictionary mapping lowercased method names to Method instances.

    Use `add()` to register a callable, or `wrap()` as a decorator.
    """

    method_type: type[T]

    def __init__(self, method_type: type[T]):
        super().__init__()
        self.method_type = method_type

    def add(self, o: Callable[..., Any]) -> T:
        m = self.method_type(o)
        self[m.name.lower()] = m
        return m

    def wrap(self, o: Callable[..., Any]) -> T:
        return self.add(o)

ArgInfo

Bases: NamedTuple

Metadata about a function argument, extracted from signature and Pydantic fields.

Used to generate CLI argument/option parsing and help text.

Source code in src/lythonic/compose/__init__.py
class ArgInfo(NamedTuple):
    """
    Metadata about a function argument, extracted from signature and Pydantic fields.

    Used to generate CLI argument/option parsing and help text.
    """

    name: str
    annotation: Any | None
    default: Any | None
    is_optional: bool
    description: str

    @classmethod
    def from_param(cls, param: inspect.Parameter, origin: Any):
        description = ""
        is_optional = param.default != inspect.Parameter.empty
        default = param.default if is_optional else None
        if isinstance(origin, type) and issubclass(origin, BaseModel):
            if param.name in origin.model_fields:
                field = origin.model_fields[param.name]
                if field.description is not None:
                    description = field.description
                is_optional = not field.is_required()
                default = field.default
        return cls(
            name=param.name,
            annotation=param.annotation if param.annotation != inspect.Parameter.empty else None,
            default=default,
            is_optional=is_optional,
            description=description,
        )

    def to_value(self, v: str):
        if self.annotation is None:
            return v
        if self.annotation is bool:
            return v.lower() in ("true", "1", "yes", "y")
        if issubclass(self.annotation, BaseModel):
            return self.annotation.model_validate_json(v)
        return self.annotation(v)

    def is_turn_on_option(self) -> bool:
        return self.annotation is bool and self.default is False

    @property
    def type(self) -> str:
        if self.annotation is not None:
            return self.annotation.__name__
        return "str"

    def arg_help(self, indent: int):
        return f"{' ' * indent}<{self.name}> - {self.type}: {self.description}"

    def opt_help(self, indent: int):
        return f"{' ' * indent}[--{self.name}{'=value' if not self.is_turn_on_option() else ''}] - {self.type}: {self.description}. Default: {self.default!r}"