Source code for click_extra.config.subcommands

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Resolution of the reserved subcommand keys a configuration file can declare.

`_default_subcommands` and `_prepend_subcommands` are read from the loaded
configuration and turned into subcommand names, which
{meth}`~click_extra.config.option.ConfigOption.handle_parse_result` splices into
the residual arguments Click dispatches on.

```{note}
The keys are honored by whichever group carries the `--config` option, not only
by click-extra's own {class}`~click_extra.commands.Group`. Injection happens at
parse time, from the option itself, because a plain `click.Group` offers
click-extra no other hook: a third-party framework building its group on
`click.Group` still gets the feature.
```

```{caution}
The option never sees two cases. Click prints the `no_args_is_help` screen of a
bare invocation before it processes any option, and a group reached through an
ancestor's `--config` carries no option of its own.
{class}`~click_extra.config.option.ConfigOption` replaces `click.Group.parse_args`
for the whole process to cover both, whatever class the group is built on: see its
documentation.
```
"""

from __future__ import annotations

import logging

import click

from .. import context
from .schema import DEFAULT_SUBCOMMANDS_KEY, PREPEND_SUBCOMMANDS_KEY

TYPE_CHECKING = False
if TYPE_CHECKING:
    from typing import Any

logger = logging.getLogger(__name__)


def _descend_to_group_config(ctx: click.Context) -> dict[str, Any] | None:
    """Return the loaded config section for the current group's command path.

    Reads the full configuration document from `ctx.meta`, descends into the
    root command's section, then walks from the root context down to `ctx`
    following each group name. Returns the resolved mapping, or `None` when no
    configuration was loaded or any segment along the path is missing.
    """
    full_config = context.get(ctx, context.CONF_FULL)
    if not full_config:
        return None

    root_ctx = ctx.find_root()
    config_branch = full_config.get(root_ctx.command.name)
    if not isinstance(config_branch, dict):
        return None

    # Walk from root context down to the current group.
    path: list[str] = []
    current: click.Context | None = ctx
    while current is not None and current is not root_ctx:
        if current.command.name is not None:
            path.append(current.command.name)
        current = current.parent
    path.reverse()

    for segment in path:
        config_branch = config_branch.get(segment)
        if not isinstance(config_branch, dict):
            return None

    return config_branch


def _dedupe_subcommands(raw: list[str], key: str) -> list[str]:
    """Drop duplicate subcommand names, keeping the first occurrence.

    Warns when duplicates are dropped, naming the configuration `key` they
    came from.
    """
    seen: set[str] = set()
    deduped: list[str] = []
    for name in raw:
        if name in seen:
            continue
        seen.add(name)
        deduped.append(name)
    if len(deduped) < len(raw):
        logger.warning(
            f"Duplicate entries in {key}: {raw!r}. "
            f"Keeping first occurrences: {deduped!r}."
        )
    return deduped


def _read_subcommand_list(
    ctx: click.Context,
    group: click.Group,
    key: str,
) -> list[str] | None:
    """Read, validate, dedupe, and existence-check a subcommand-list config key.

    Returns the deduplicated list of subcommand names declared under `key`
    in the loaded configuration, or `None` when the key is absent or empty.
    Shared by {func}`resolve_default_subcommands` and
    {func}`resolve_prepend_subcommands`; each caller layers on its own chain-mode
    rule (the only behavior that differs between the two keys).

    :raises click.UsageError: when the value is not a list of strings, or when
        a listed subcommand does not exist in `group`.
    """
    config_branch = _descend_to_group_config(ctx)
    if config_branch is None:
        return None

    raw = config_branch.get(key)
    if raw is None:
        return None

    # Validate type.
    if not isinstance(raw, list) or not all(isinstance(s, str) for s in raw):
        raise click.UsageError(f"{key} must be a list of strings, got {raw!r}.")

    if not raw:
        return None

    raw = _dedupe_subcommands(raw, key)

    # Validate that all subcommands exist.
    for name in raw:
        if group.get_command(ctx, name) is None:
            raise click.UsageError(
                f"Subcommand {name!r} from {key} not found in group {group.name!r}."
            )

    return raw


[docs] def resolve_default_subcommands( ctx: click.Context, group: click.Group, ) -> list[str] | None: """Read and validate `_default_subcommands` from the loaded configuration.""" raw = _read_subcommand_list(ctx, group, DEFAULT_SUBCOMMANDS_KEY) if raw is None: return None # Non-chained groups can only have one default subcommand. if not group.chain and len(raw) > 1: raise click.UsageError( f"Non-chained group {group.name!r} can have at most 1 default " f"subcommand, got {len(raw)}: {raw!r}." ) return raw
[docs] def resolve_prepend_subcommands( ctx: click.Context, group: click.Group, ) -> list[str] | None: """Read and validate `_prepend_subcommands` from the loaded configuration.""" raw = _read_subcommand_list(ctx, group, PREPEND_SUBCOMMANDS_KEY) if raw is None: return None # Prepend subcommands only work with chained groups. if not group.chain: raise click.UsageError( f"{PREPEND_SUBCOMMANDS_KEY} requires chain=True on group {group.name!r}." ) return raw
[docs] def inject_reserved_subcommands(ctx: click.Context, args: list[str]) -> list[str]: """Return `args` with the reserved subcommand keys applied. `_default_subcommands` fills in for an invocation that names no subcommand. The command line always wins: when `args` already carries one, the configured defaults are logged and dropped. `_prepend_subcommands` goes in front of whatever remains, whether the subcommand came from the command line or from the defaults. It requires a `chain=True` group. Applies at most once per group, tracked in {data}`~click_extra.context.SUBCOMMANDS_APPLIED`. A group can be visited twice, by the pre-pass of a bare invocation and then by the regular parameter loop, and prepending the same subcommand on both passes would run it twice. :raises click.UsageError: on an invalid value, an unknown subcommand, or a chain-mode violation. """ group = ctx.command if not isinstance(group, click.Group): return args applied = context.get(ctx, context.SUBCOMMANDS_APPLIED) if applied is None: applied = set() context.set(ctx, context.SUBCOMMANDS_APPLIED, applied) if ctx.command_path in applied: return args applied.add(ctx.command_path) default_subcmds = resolve_default_subcommands(ctx, group) if default_subcmds is not None: if args: logger.debug( f"CLI subcommands provided; ignoring {DEFAULT_SUBCOMMANDS_KEY}" f" config: {default_subcmds!r}." ) else: args = list(default_subcmds) prepend_subcmds = resolve_prepend_subcommands(ctx, group) if prepend_subcmds is not None: logger.info( f"Prepending {PREPEND_SUBCOMMANDS_KEY} config: {prepend_subcmds!r}." ) args = list(prepend_subcmds) + args return args