Source code for chaintool.command_impl_print

# -*- coding: utf-8 -*-
#
# Copyright 2021 Joel Baxter
#
# This file is part of chaintool.
#
# chaintool 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 3 of the License, or
# (at your option) any later version.
#
# chaintool 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 chaintool.  If not, see <https://www.gnu.org/licenses/>.

"""Low-level logic for "cmd" operations related to pretty-printing.

Called from command, sequence, command_impl, and sequence_impl_op modules.

"""


__all__ = [
    "print_one",
    "print_multi",
]


import shlex

from colorama import Fore

from . import command_impl_core
from . import shared
from . import virtual_tools





def init_print_info_collections(  # pylint: disable=too-many-arguments
    commands,
    command_dicts,
    command_dicts_by_cmd,
    commands_by_placeholder,
    placeholders_sets,
    ignore_env,
):
    """Gather info useful to pretty-print multiple commands.

    Iterate through the ``commands``, for each command reading its dictionary
    and using the args info (placeholders+values) to populate the various
    inout collections arguments.

    Populating ``command_dicts``, ``command_dicts_by_cmd``, and
    ``commands_by_placeholder`` is straightforward.

    Deciding which "set" a placeholder name belongs in, to populate
    ``placeholders_sets``, goes according to the following rules:

    - If a placeholder lacks a default value in some command, put it in the
      "required" set.
    - If a placeholder has some default everywhere it appears -- either
      because of its definition in the commandline or because of the use
      of chaintool-env (if ``ignore_env`` is False) -- put it in the
      "optional" set.
    - Any toggle placeholder goes into the "toggle" set.

    Finally, return a string that lists the command names, with any
    non-existing command highlighted in red.

    :param commands:                list of command names
    :type commands:                 list[str]
    :param command_dicts:           list of command dictionaries, initially
                                    empty; to modify
    :type command_dicts:            list[dict[str, str]]
    :param command_dicts_by_cmd:    dict of command dictionaries, keyed by
                                    command name, initially empty; to modify
    :type command_dicts_by_cmd:     dict[str, dict[str, str]]
    :param commands_by_placeholder: dict keyed by placeholder name where the
                                    value is a list of names of commands where
                                    that placeholder appears, initially empty;
                                    to modify
    :type commands_by_placeholder:  dict[str, dict[str, str]]
    :param placeholders_sets:       dict with keys "required", "optional",
                                    and "toggle", where each value is a set
                                    of placeholders of that type, sets
                                    initially empty; to modify
    :type placeholders_sets:        dict[str, set[str]]
    :param ignore_env:              whether to ignore the effects of
                                    chaintool-env; will be True for
                                    non-sequence prints
    :type ignore_env:               bool

    :returns: pretty-printed string containing command names
    :rtype:   str

    """

    def record_placeholder(cmd, placeholder):
        """Mark that a command uses a placeholder.

        Add the input usage info to the commands_by_placeholder dict.

        :param cmd:         command name
        :type cmd:          str
        :param placeholder: placeholder name used by the command
        :type placeholder:  str

        """
        if placeholder in commands_by_placeholder:
            commands_by_placeholder[placeholder].append(cmd)
        else:
            commands_by_placeholder[placeholder] = [cmd]

    commands_display = ""
    env_values = dict()
    for cmd in commands:
        try:
            cmd_dict = command_impl_core.read_dict(cmd)
        except FileNotFoundError:
            commands_display += " " + Fore.RED + cmd + Fore.RESET
            continue
        commands_display += " " + cmd
        cmd_dict["name"] = cmd
        command_dicts.append(cmd_dict)
        command_dicts_by_cmd[cmd] = cmd_dict
        for key, value in cmd_dict["args"].items():
            if key in env_values:
                value = Fore.GREEN + env_values[key] + Fore.RESET
                cmd_dict["args"][key] = value
            record_placeholder(cmd, key)
            if value is None:
                placeholders_sets["required"].add(key)
                placeholders_sets["optional"].discard(key)
            else:
                if key not in placeholders_sets["required"]:
                    placeholders_sets["optional"].add(key)
        for key in cmd_dict["toggle_args"].keys():
            record_placeholder(cmd, key)
            placeholders_sets["toggle"].add(key)
        if not ignore_env:
            virtual_tools.update_env(cmd_dict["cmdline"], env_values)
    return commands_display[1:]


def print_group_args(group, group_args, build_format_fun):
    """Print the placeholders used in a group of commands.

    For every "arg" (placeholder name) in ``group_args``, iterate through the
    commands in ``group`` invoking ``build_format_fun`` repeatedly. This
    function returns a tuple of: are we done yet (boolean), updated format
    string, and an updated values list to apply to that format string.

    When ``build_format_fun`` returns ``True`` as the first value of that
    return tuple, stop the iteration-over-commands. Print using the
    last-returned format string with the last-returned format values, and then
    move on to processing the next placeholder in ``group_args``.

    Note that the format values returned by ``build_format_fun`` are
    guaranteed to correctly populate the placeholders in the format string
    but may (in fact, will) have additional elements at the end of the list
    for various reasons.

    :param group:            list of command names
    :type group:             list[str]
    :param group_args:       list of placeholders (with values, if any) common
                             to that group of commands
    :type group_args:        list[str]
    :param build_format_fun: func used to gradually build a format string
                             for printing (see above)
    :type build_format_fun:  Callable[
                                 [str, str, str | None, str | None],
                                 tuple[bool, str, list[str | None]]
                             ]

    """
    first_cmd = group[0]
    for arg in group_args:
        done, format_str, format_args = build_format_fun(arg, first_cmd)
        if not done:
            for cmd in group[1:]:
                done, format_str, format_args = build_format_fun(
                    arg, cmd, format_str, format_args
                )
                if done:
                    break
        print(format_str.format(*format_args))


def print_command_groups(cmd_group_args, command_dicts_by_cmd):
    """Print the placeholders used in every group of commands.

    The bulk of this function is really in the definition of the formatter
    function :func:`build_format` passed to :func:`print_group_args`, so see
    the docstring for that.

    Once that function is defined, for each command group pretty-print a
    header and then call :func:`print_group_args`.

    :param cmd_group_args:       list of 2-tuples containing: a list of
                                 commands, and a list of placeholders those
                                 commands have in common
    :type cmd_group_args:        list[tuple[list[str], list[str]]]
    :param command_dicts_by_cmd: dict of command dictionaries, keyed by
                                 command name
    :type command_dicts_by_cmd:  dict[str, dict[str, str]]

    """
    _, firstargs = cmd_group_args[0]
    if firstargs[0][0] == "+":
        args_dict_name = "toggle_args"
        vals_per_arg = 2
        multival_str_suffix = "{}:{} ({})"
        common_format_str = "{} = {}:{}"
    else:
        args_dict_name = "args"
        vals_per_arg = 1
        multival_str_suffix = "{} ({})"
        common_format_str = "{} = {}"

    def build_format(arg, cmd, format_str=None, format_args=None):
        """Iteratively build a format string+values to print placeholder info.

        The general idea is to look up the default value that the given
        placeholder has assigned for the given command, and add on to the
        format string/values the stuff necessary to display that. As this
        function is invoked for all commands that use a given placeholder, the
        complete set of format info is built.

        More specifically:

        If the placeholder is found to lack a default value in any command,
        return a simple set of format info that will only display the
        placeholder name. Also return ``True`` for the are-we-done result. The
        idea is that this placeholder is required to be specified, so even if
        it has default values in some commands, those values don't matter.

        As long as the placeholder has the same default value in all commands
        so far (which should be a common case), a simple set of format info
        is returned that will show the placeholder name and value, not broken
        out per-command. We actually smuggle a "common value so far" element
        at the end of the values list to help track this.

        If this "common value" condition is broken, switch to generating
        format info that will break out the displayed default values
        per-command.

        :param arg:         placeholder name
        :type arg:          str
        :param cmd:         name of a command that uses the placeholder
        :type cmd:          str
        :param format_str:  format string built so far; defaults to None
        :type format_str:   str | None, optional
        :param format_args: list of format values (and whatever else we need
                            to pass between iterations); defaults to None
        :type format_args:  list[str | None] | None, optional

        :returns: tuple of "done yet", format str, and format values
        :rtype:   tuple[bool, str, list[str | None]]

        """
        value = command_dicts_by_cmd[cmd][args_dict_name][arg]
        # Early return if no default value; user will be required to specify.
        if value is None:
            return True, "{}", [arg]
        # Prepare the format values to be added.
        if vals_per_arg == 1:
            args_suffix = [shlex.quote(value), cmd]
        else:
            args_suffix = [shlex.quote(value[0]), shlex.quote(value[1]), cmd]
        # If this is first invocation for this arg, return initial info.
        if format_str is None:
            format_args = [arg] + args_suffix + [value]
            return False, common_format_str, format_args
        # Pop off the "common value so far" being smuggled in the values list.
        actual_format_args, common_value = format_args[:-1], format_args[-1]
        # If value is still common, return the info for that case.
        if value == common_value:
            format_args = actual_format_args + args_suffix + [common_value]
            return False, common_format_str, format_args
        # If this is the first time we're noticing a non-common value,
        # "backfill" the format string to show all prior commands+values.
        if common_value is not None:
            catch_up = (len(actual_format_args) - 1) // (vals_per_arg + 1)
            format_str = "{} = " + ", ".join([multival_str_suffix] * catch_up)
        # Now go ahead and extend the format info for this command+value.
        format_str = ", ".join([format_str, multival_str_suffix])
        format_args = actual_format_args + args_suffix + [None]
        return False, format_str, format_args

    for group, args in cmd_group_args:
        print(Fore.CYAN + "* " + ", ".join(group) + Fore.RESET)
        args.sort()
        print_group_args(group, args, build_format)


def print_placeholders_set(
    placeholders_set, sortfunc, command_dicts_by_cmd, commands_by_placeholder
):
    """Print info for all placeholders of a given type.

    The ``placeholders_set`` input is a set of placeholder names that should
    be displayed in the same block together for pretty-print purposes, e.g.
    "all placeholders that lack a default value".

    The first task here is to group placeholders by the commands where they
    appear. E.g. so we can say that commands x and y both use placeholders
    a, b, and c. Build a list of 2-tuples, where the first element of the
    tuple is a list of commands, and the second element is a list of the
    placeholders used by all of those commands.

    Use the ``sortfunc`` to sort this list in a desirable way (see
    :func:`print_multi` for more details) and then invoke
    :func:`print_command_groups` to pretty-print the list.

    :param placeholders_set:        placeholder names of a given type
    :type placeholders_set:         set[str]
    :param sortfunc:                function used to sort list of tuples
                                    associating command groups with
                                    placeholders
    :type sortfunc:                 Callable[
                                        [tuple[list[str], list[str]]],
                                        int
                                    ]
    :param command_dicts_by_cmd:    dict of command dictionaries, keyed by
                                    command name
    :type command_dicts_by_cmd:     dict[str, dict[str, str]]
    :param commands_by_placeholder: dict keyed by placeholder name where the
                                    value is a list of names of commands where
                                    that placeholder appears, initially empty;
                                    to modify
    :type commands_by_placeholder:  dict[str, dict[str, str]]

    """

    def args_updater(arg, oldargs, update_checker):
        """Utility for doing compact element updates during comprehension.

        Add ``arg`` to the ``oldargs`` list, and add ``True`` to the
        ``update_checker`` list.

        :param arg:            placeholder name to process
        :type arg:             str
        :param oldargs:        current list of placeholder names in element
        :type oldargs:         str
        :param update_checker: list used to track whether this function has
                               been called during the comprehension
        :type update_checker:  list[bool]

        """
        oldargs.append(arg)
        update_checker.append(True)
        return oldargs

    cmd_group_args = []
    for arg in placeholders_set:
        cmd_group = commands_by_placeholder[arg]
        update_checker = []
        # The syntax here is a bit much, but the gist is: if there's already
        # an element in this list with the same command group, add this
        # placeholder to the list of placeholders there, using the
        # args_updater function. For all other elements that don't match the
        # command group, leave them unchanged.
        cmd_group_args = [
            (group, args_updater(arg, group_args, update_checker))
            if group == cmd_group
            else (group, group_args)
            for (group, group_args) in cmd_group_args
        ]
        # If args_updater was not called above, i.e. there was no element
        # with a matching command group, then let's add one.
        if not update_checker:
            newentry = (cmd_group, [arg])
            cmd_group_args.append(newentry)
    cmd_group_args.sort(key=sortfunc, reverse=True)
    print_command_groups(cmd_group_args, command_dicts_by_cmd)