Source code for interfacy.click_backend.commands

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

import click
from click.formatting import iter_rows, measure_table, term_len, wrap_text

from interfacy.appearance.layout import HelpLayout
from interfacy.appearance.renderer import SchemaHelpRenderer
from interfacy.click_backend.parser import InterfacyOptionParser

if TYPE_CHECKING:
    from interfacy.schema.schema import Argument, Command, ParserSchema


def _uses_template_layout(layout: HelpLayout) -> bool:
    layout_mode = layout.layout_mode
    if layout_mode == "template":
        return True
    if layout_mode == "adaptive":
        return False

    return bool(layout.format_option or layout.format_positional)


class InterfacyClickHelpFormatter(click.HelpFormatter):
    """Click formatter that can pin help descriptions to an absolute column."""

    def __init__(
        self,
        *,
        help_position: int | None = None,
        **kwargs: Any,
    ) -> None:
        super().__init__(**kwargs)

        self.interfacy_help_position = help_position

    def write_dl(
        self,
        rows: Sequence[tuple[str, str]],
        col_max: int = 30,
        col_spacing: int = 2,
    ) -> None:
        target = self.interfacy_help_position
        if target is None:
            super().write_dl(rows, col_max=col_max, col_spacing=col_spacing)
            return

        rows = list(rows)
        widths = measure_table(rows)
        if len(widths) != 2:
            raise TypeError("Expected two columns for definition list")

        first_col = max(col_spacing, target - self.current_indent)

        for first, second in iter_rows(rows, len(widths)):
            self.write(f"{'':>{self.current_indent}}{first}")
            if not second:
                self.write("\n")
                continue

            if term_len(first) <= first_col - col_spacing:
                self.write(" " * (first_col - term_len(first)))
            else:
                self.write("\n")
                self.write(" " * (first_col + self.current_indent))

            text_width = max(self.width - first_col - 2, 10)
            wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True)
            lines = wrapped_text.splitlines()

            if lines:
                self.write(f"{lines[0]}\n")
                for line in lines[1:]:
                    self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
            else:
                self.write("\n")


class InterfacyClickOption(click.Option):
    """Normalize Click option help records to omit metavar suffixes."""

    def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None:
        """
        Return a cleaned help-record tuple for one option.

        Args:
            ctx (click.Context): Active Click context.
        """
        help_record = super().get_help_record(ctx)
        if help_record is not None:
            name, help_text = help_record
            if " " in name and not self.is_flag:
                name = name.rsplit(" ", 1)[0]

            return name, help_text

        return None


class InterfacyListOption(InterfacyClickOption):
    """Accept repeated values for list-like options while preserving None defaults."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        kwargs["nargs"] = 1
        super().__init__(*args, **kwargs)

        self.nargs = -1
        self._interfacy_none_default = kwargs.get("default", None) is None

    def type_cast_value(self, ctx: click.Context, value: Any) -> Any:
        """
        Cast a list option value while preserving explicit None defaults.

        Args:
            ctx (click.Context): Active Click context.
            value (object): Raw parsed value from Click.
        """
        if value is None and self._interfacy_none_default:
            return None

        return super().type_cast_value(ctx, value)


class InterfacyClickArgument(click.Argument):
    """Carry argument help text and normalize argument help-row names."""

    def __init__(
        self,
        param_decls: Sequence[str],
        required: bool | None = None,
        help: str | None = None,  # noqa: A002 - preserve click-style keyword
        **attrs: Any,
    ) -> None:
        self.help = help
        super().__init__(param_decls, required=required, **attrs)

    def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None:
        """
        Return a cleaned help-record tuple for one positional argument.

        Args:
            ctx (click.Context): Active Click context.
        """
        help_record = super().get_help_record(ctx)
        if help_record is not None:
            name, help_text = help_record
            parts = name.split(" ")
            name = " ".join(parts[:-1])

            return name, help_text

        return None


class HelpMixin:
    interfacy_schema: Command | None = None
    interfacy_parser_schema: ParserSchema | None = None
    interfacy_aliases: tuple[str, ...] = ()
    interfacy_epilog: str | None = None
    interfacy_is_root: bool = False
    params: list[click.Parameter]
    interfacy_param_bindings: dict[str, str]
    interfacy_arg_specs: dict[str, Argument]
    interfacy_suppress_defaults: set[str]
    interfacy_help_position: int | None = None
    interfacy_help_position_explicit: bool = False

    def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
        if isinstance(formatter, InterfacyClickHelpFormatter):
            positional_rows = self._positionals_help_rows(ctx)
            if positional_rows:
                with formatter.section("Positionals"):
                    formatter.write_dl(positional_rows)

        return super().format_options(ctx, formatter)

    def _resolve_fallback_help_position(self) -> int | None:
        help_position = self.interfacy_help_position
        if not self.interfacy_help_position_explicit or not isinstance(help_position, int):
            return None

        return help_position

    def _render_click_help(self, ctx: click.Context) -> str:
        formatter = InterfacyClickHelpFormatter(
            width=ctx.terminal_width,
            max_width=ctx.max_content_width,
            help_position=self._resolve_fallback_help_position(),
        )
        self.format_help(ctx, formatter)
        return formatter.getvalue().rstrip("\n")

    def _positionals_help_rows(self, ctx: click.Context) -> list[tuple[str, str]]:
        rows: list[tuple[str, str]] = []
        for param in self.params:
            if not isinstance(param, InterfacyClickArgument):
                continue

            help_record = param.get_help_record(ctx)
            if help_record is None:
                name = param.name or ""
                rows.append((name, param.help or ""))
                continue

            rows.append(help_record)

        return rows

    def _augment_help(self, _ctx: click.Context, original_help: str) -> str:
        if "Options:" in original_help:
            description, opts = original_help.split("Options:", 1)
            options = "\n\nOptions:" + opts
        else:
            description = original_help
            options = ""

        positional_lines = []
        for param in self.params:
            if isinstance(param, InterfacyClickArgument):
                positional_name = f"{param.name}".ljust(16)
                arg_help = f"  {positional_name} {param.help or ''}".rstrip()
                positional_lines.append(arg_help)

        extra_help = ""
        if positional_lines:
            extra_help = "Positionals:\n" + "\n".join(positional_lines) + "\n"

        merged = description + extra_help + options
        if self.interfacy_epilog:
            merged = f"{merged.rstrip()}\n\n{self.interfacy_epilog}".rstrip()

        return merged


[docs] class InterfacyClickCommand(HelpMixin, click.Command): """Render command help with Interfacy schema-aware formatting.""" def make_parser(self, ctx: click.Context) -> InterfacyOptionParser: """ Build an option parser bound to this command's parameters. Args: ctx (click.Context): Active Click context. """ parser = InterfacyOptionParser(ctx) for param in self.get_params(ctx): param.add_to_parser(parser, ctx) return parser def get_help(self, ctx: click.Context) -> str: """ Render command help using schema-aware formatting when available. Args: ctx (click.Context): Active Click context. """ schema = self.interfacy_parser_schema if schema is not None: renderer = SchemaHelpRenderer(schema.theme, terminal_width=ctx.terminal_width) return renderer.render_parser_help(schema, ctx.command_path) schema_command = self.interfacy_schema if schema_command is not None and schema_command.help_layout is not None: renderer = SchemaHelpRenderer( schema_command.help_layout, terminal_width=ctx.terminal_width ) return renderer.render_command_help(schema_command, ctx.command_path) if self._resolve_fallback_help_position() is not None: help_text = self._render_click_help(ctx) if self.interfacy_epilog: return f"{help_text.rstrip()}\n\n{self.interfacy_epilog}".rstrip() return help_text original_help = super().get_help(ctx) return self._augment_help(ctx, original_help)
[docs] class InterfacyClickGroup(HelpMixin, click.Group): """Resolve group aliases and render group help with schema metadata.""" def make_parser(self, ctx: click.Context) -> InterfacyOptionParser: """ Build an option parser bound to this group's parameters. Args: ctx (click.Context): Active Click context. """ parser = InterfacyOptionParser(ctx) for param in self.get_params(ctx): param.add_to_parser(parser, ctx) return parser def get_command(self, ctx: click.Context, name: str) -> click.Command | None: """ Resolve a subcommand by canonical name first, then by Interfacy aliases. Args: ctx (click.Context): Active Click context. name (str): Command token from CLI input. """ command = super().get_command(ctx, name) if command is not None: return command for sub_cmd in self.commands.values(): aliases = ( sub_cmd.interfacy_aliases if isinstance(sub_cmd, (InterfacyClickCommand, InterfacyClickGroup)) else () ) if name in aliases: return sub_cmd return None def list_commands(self, _ctx: click.Context) -> list[str]: """Return canonical subcommand names in insertion order.""" return list(self.commands.keys()) def get_help(self, ctx: click.Context) -> str: """ Render group help using schema-aware formatting when available. Args: ctx (click.Context): Active Click context. """ schema = self.interfacy_parser_schema if schema is not None: renderer = SchemaHelpRenderer(schema.theme, terminal_width=ctx.terminal_width) return renderer.render_parser_help(schema, ctx.command_path) schema_command = self.interfacy_schema if schema_command is not None and schema_command.help_layout is not None: renderer = SchemaHelpRenderer( schema_command.help_layout, terminal_width=ctx.terminal_width ) return renderer.render_command_help(schema_command, ctx.command_path) if self._resolve_fallback_help_position() is not None: help_text = self._render_click_help(ctx) if self.interfacy_epilog: return f"{help_text.rstrip()}\n\n{self.interfacy_epilog}".rstrip() return help_text original_help = super().get_help(ctx) return self._augment_help(ctx, original_help)
__all__ = [ "InterfacyClickArgument", "InterfacyClickCommand", "InterfacyClickGroup", "InterfacyClickOption", "InterfacyListOption", ]