from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from inspect import isroutine
from typing import Any, Literal
from interfacy.appearance.help_sort import (
HelpOptionSortRule,
HelpSubcommandSortRule,
resolve_help_option_sort_rules,
resolve_help_subcommand_sort_rules,
)
from interfacy.exceptions import ConfigurationError, DuplicateCommandError
from interfacy.executable_flag import ExecutableFlag, normalize_executable_flags
from interfacy.parameters import Param, ParameterSettingsInput, normalize_parameter_settings
from interfacy.pipe import PipeTargets, build_pipe_targets_config
from interfacy.util import validate_help_group
AbbreviationScope = Literal["top_level_options", "all_options"]
ABBREVIATION_SCOPE_VALUES: tuple[AbbreviationScope, ...] = ("top_level_options", "all_options")
def validate_abbreviation_scope(value: AbbreviationScope | None) -> AbbreviationScope | None:
if value is None:
return None
if value not in ABBREVIATION_SCOPE_VALUES:
raise ConfigurationError(
"abbreviation_scope must be one of: " + ", ".join(ABBREVIATION_SCOPE_VALUES)
)
return value
def validate_help_option_sort(
value: Any,
) -> list[HelpOptionSortRule] | None:
if value is None:
return None
return resolve_help_option_sort_rules(value, value_name="help_option_sort")
def validate_help_subcommand_sort(
value: Any,
) -> list[HelpSubcommandSortRule] | None:
if value is None:
return None
return resolve_help_subcommand_sort_rules(value, value_name="help_subcommand_sort")
def validate_model_expansion_max_depth(value: int | None) -> int | None:
if value is None:
return None
if value < 1:
raise ConfigurationError("model_expansion_max_depth must be >= 1")
return value
def validate_method_skips(value: Sequence[str] | None) -> list[str] | None:
if value is None:
return None
if isinstance(value, str) or not isinstance(value, Sequence):
raise ConfigurationError("method_skips must be a sequence of strings")
result: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
raise ConfigurationError("method_skips values must be strings")
if item in seen:
continue
result.append(item)
seen.add(item)
return result
[docs]
@dataclass
class CommandEntry:
"""Internal representation of a command added to a group."""
obj: Callable[..., Any] | type | Any
name: str
description: str | None
aliases: tuple[str, ...]
is_instance: bool
pipe_targets: PipeTargets | None = None
include_inherited_methods: bool | None = None
include_protected_methods: bool | None = None
include_private_methods: bool | None = None
include_staticmethods: bool | None = None
include_classmethods: bool | None = None
method_skips: list[str] | None = None
expand_model_params: bool | None = None
model_expansion_max_depth: int | None = None
abbreviation_scope: AbbreviationScope | None = None
help_option_sort: list[HelpOptionSortRule] | None = None
help_subcommand_sort: list[HelpSubcommandSortRule] | None = None
help_group: str | None = None
executable_flags: list[ExecutableFlag] | None = None
parameter_settings: dict[str, Param] | None = None
[docs]
@dataclass
class SubgroupEntry:
"""Internal representation of a subgroup added to a group."""
group: CommandGroup
help_group: str | None = None
executable_flags: list[ExecutableFlag] | None = None
[docs]
class CommandGroup:
"""
A command group for building nested CLI hierarchies.
Supports manual construction of deeply nested command structures:
- add_command(function) -> leaf command
- add_command(class) -> subgroup with methods as commands
- add_command(instance) -> subgroup with methods as commands (no __init__ args)
- add_group(CommandGroup) -> nested subgroup
"""
def __init__(
self,
name: str,
description: str | None = None,
aliases: tuple[str, ...] | list[str] | None = None,
) -> None:
self.name = name
self.description = description
self.aliases = tuple(aliases) if aliases else ()
self._commands: dict[str, CommandEntry] = {}
self._subgroups: dict[str, SubgroupEntry] = {}
self._group_args_source: type | Callable[..., Any] | None = None
[docs]
def add_command(
self,
command: Callable[..., Any] | type | Any,
name: str | None = None,
description: str | None = None,
aliases: tuple[str, ...] | list[str] | None = None,
pipe_targets: PipeTargets | dict[str, Any] | Sequence[str] | str | None = None,
include_inherited_methods: bool | None = None,
include_protected_methods: bool | None = None,
include_private_methods: bool | None = None,
include_staticmethods: bool | None = None,
include_classmethods: bool | None = None,
method_skips: Sequence[str] | None = None,
expand_model_params: bool | None = None,
model_expansion_max_depth: int | None = None,
abbreviation_scope: AbbreviationScope | None = None,
help_option_sort: list[HelpOptionSortRule] | None = None,
help_subcommand_sort: list[HelpSubcommandSortRule] | None = None,
help_group: str | None = None,
executable_flags: list[ExecutableFlag] | None = None,
parameter_settings: ParameterSettingsInput | None = None,
) -> CommandGroup:
"""
Add a command to this group.
Args:
command: Function, class, or class instance to add.
name: Override the command name (defaults to function/class name).
description: Override the description.
aliases: Alternative names for this command.
pipe_targets: Configure stdin piping for this command.
include_inherited_methods: Override inherited-method inclusion.
include_protected_methods: Override protected-method inclusion.
include_private_methods: Override private-method inclusion.
include_staticmethods: Override staticmethod inclusion.
include_classmethods: Override classmethod inclusion.
method_skips: Override class method skip list.
expand_model_params: Override model expansion toggle.
model_expansion_max_depth: Override model expansion depth.
abbreviation_scope: Override abbreviation scope.
help_option_sort: Override help option sort rules.
help_subcommand_sort: Override help subcommand sort rules.
help_group: Optional help-only group heading for this command in help listings.
executable_flags: Zero-argument executable flags registered on this command node.
parameter_settings: Per-parameter CLI settings keyed by parameter name.
"""
resolved_abbreviation_scope = validate_abbreviation_scope(abbreviation_scope)
resolved_help_option_sort = validate_help_option_sort(help_option_sort)
resolved_help_subcommand_sort = validate_help_subcommand_sort(help_subcommand_sort)
resolved_model_expansion_max_depth = validate_model_expansion_max_depth(
model_expansion_max_depth
)
resolved_help_group = validate_help_group(help_group)
resolved_method_skips = validate_method_skips(method_skips)
resolved_executable_flags = normalize_executable_flags(
executable_flags,
value_name="executable_flags",
)
resolved_parameter_settings = normalize_parameter_settings(parameter_settings)
resolved_pipe_targets = (
build_pipe_targets_config(pipe_targets) if pipe_targets is not None else None
)
is_instance = self._is_instance_command(command)
cmd_name: str
if isinstance(command, type):
cmd_name = name or command.__name__
elif not is_instance and callable(command):
callable_name = getattr(command, "__name__", None)
fallback_name = (
callable_name if isinstance(callable_name, str) else type(command).__name__
)
cmd_name = name or fallback_name
else:
cmd_name = name or type(command).__name__
self._ensure_unique_child_name(cmd_name)
if description is None and hasattr(command, "__doc__") and command.__doc__:
description = command.__doc__.split("\n")[0].strip()
entry = CommandEntry(
obj=command,
name=cmd_name,
description=description,
aliases=tuple(aliases) if aliases else (),
pipe_targets=resolved_pipe_targets,
is_instance=is_instance,
include_inherited_methods=include_inherited_methods,
include_protected_methods=include_protected_methods,
include_private_methods=include_private_methods,
include_staticmethods=include_staticmethods,
include_classmethods=include_classmethods,
method_skips=resolved_method_skips,
expand_model_params=expand_model_params,
model_expansion_max_depth=resolved_model_expansion_max_depth,
abbreviation_scope=resolved_abbreviation_scope,
help_option_sort=list(resolved_help_option_sort) if resolved_help_option_sort else None,
help_subcommand_sort=list(resolved_help_subcommand_sort)
if resolved_help_subcommand_sort
else None,
help_group=resolved_help_group,
executable_flags=list(resolved_executable_flags) if resolved_executable_flags else None,
parameter_settings=(
dict(resolved_parameter_settings) if resolved_parameter_settings else None
),
)
self._commands[cmd_name] = entry
return self
[docs]
def add_group(
self,
group: CommandGroup,
name: str | None = None,
help_group: str | None = None,
executable_flags: list[ExecutableFlag] | None = None,
) -> CommandGroup:
"""
Add a nested subgroup.
Args:
group: The CommandGroup to add as a subgroup.
name: Override the subgroup name.
help_group: Optional help-only group heading for this subgroup command.
executable_flags: Zero-argument executable flags registered on the subgroup node.
"""
group_name = name or group.name
self._ensure_unique_child_name(group_name)
resolved_help_group = validate_help_group(help_group)
resolved_executable_flags = normalize_executable_flags(
executable_flags,
value_name="executable_flags",
)
self._subgroups[group_name] = SubgroupEntry(
group=group,
help_group=resolved_help_group,
executable_flags=(
list(resolved_executable_flags) if resolved_executable_flags else None
),
)
return self
[docs]
def with_args(self, source: type | Callable[..., Any]) -> CommandGroup:
"""
Set group-level arguments from a class __init__ or function signature.
Args:
source: A class (uses __init__ params) or callable (uses signature).
"""
self._group_args_source = source
return self
@property
def commands(self) -> dict[str, CommandEntry]:
"""Return a copy of the commands dictionary."""
return dict(self._commands)
@property
def subgroups(self) -> dict[str, CommandGroup]:
"""Return a copy of the subgroups dictionary."""
return {name: entry.group for name, entry in self._subgroups.items()}
@property
def subgroup_entries(self) -> dict[str, SubgroupEntry]:
"""Return subgroup entries with associated metadata."""
return dict(self._subgroups)
@property
def has_subgroups(self) -> bool:
"""Whether this group contains nested subgroups."""
return len(self._subgroups) > 0
@property
def has_commands(self) -> bool:
"""Whether this group contains direct commands."""
return len(self._commands) > 0
@property
def is_empty(self) -> bool:
"""Whether this group has no commands or subgroups."""
return not self.has_commands and not self.has_subgroups
def __repr__(self) -> str:
return (
f"CommandGroup(name={self.name!r}, "
f"commands={list(self._commands.keys())}, "
f"subgroups={list(self._subgroups.keys())})"
)
def _ensure_unique_child_name(self, name: str) -> None:
if name in self._commands or name in self._subgroups:
raise DuplicateCommandError(name)
@staticmethod
def _is_instance_command(command: Callable[..., Any] | type | Any) -> bool:
if isinstance(command, type):
return False
if not callable(command):
return True
if isroutine(command):
return False
for attr_name in dir(type(command)):
if attr_name.startswith("_") or attr_name == "__call__":
continue
attr = getattr(type(command), attr_name, None)
if callable(attr):
return True
return False
__all__ = ["CommandEntry", "CommandGroup", "SubgroupEntry"]