Skip to content

SOURCE CODE pyparam.params DOCS

"""Definition of Params"""
import sys
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple, Type, Union

import rich
from diot import Diot, OrderedDiot
from simpleconf import Config

from .completer import Completer
from .defaults import PARAM as PARAM_DEFAULT
from .defaults import PARAMS as PARAMS_DEFAULT
from .defaults import POSITIONAL
from .exceptions import PyParamNameError, PyParamTypeError, PyParamValueError
from .help import HelpAssembler, ProgHighlighter
from .param import PARAM_MAPPINGS, ParamNamespace
from .utils import (
    Namespace,
    always_list,
    logger,
    parse_potential_argument,
    parse_type,
    type_from_value,
)

if TYPE_CHECKING:
    from .help import Theme
    from .param import Param, ParamPath


class Params(Completer):DOCS
    """Params, served as root params or subcommands

    Args:
        names: The names of this command if served as a command
        desc: The description of the command.
            This will be finally compiled into a list if a string is given.
            The difference is, when displayed on help page, the string will
            be wrapped by textwrap automatically. However, each element in
            a given list will not be wrapped.
        prog: The program name
        help_keys: The names to bring up the help information
        help_cmds: The help command names to show help of other
            subcommands
        help_on_void: Whether to show help when no arguments provided
        help_callback: A function to modify the help page
        help_modifier: A callback function to modify the help param/command
        prefix: The prefix for the arguments
            (see attribute `Params.prefix`)
        arbitrary: Whether to parse the command line arbitrarily
        theme (str|rich.theme.Theme): The theme to render the help page
        usage: Some example usages

    Attributes:
        desc: The description of the command.
        prog: The program name. Default: `sys.argv[0]`
        help_keys: The names to bring up the help information.
        help_cmds: The names of help subcommands to bring up
            help information of subcommands
        help_on_void: Whether show help when there is not arguments
            provided
        usage: The usages of this program
        prefix: The prefix for the arguments on command line
            - `auto`: Automatically determine the prefix for each argument.
                Basically, `-` for short options, and `--` for long.
                Note that `-` for `-vvv` if `v` is a count option
        arbitrary: Whether parsing the command line arbitrarily
        theme (rich.theme.Theme|str): The theme for the help page
        names: The names of the commands if this serves as sub-command
        params: The ordered dict of registered parameters
        commands: The ordered dict of registered commands
        param_groups: The ordered dict of parameter groups
        command_groups: The ordered dict of command groups
        asssembler: The asssembler to assemble the help page
    """

    def __init__(
        self,
        names: Union[str, List[str]] = None,
        desc: Union[List[str], str] = None,
        prog: str = None,
        help_keys: Union[str, List[str]] = None,
        help_cmds: Union[str, List[str]] = None,
        help_on_void: Union[str, bool] = None,
        help_callback: Callable = None,
        help_modifier: Callable = None,
        fullopt_keys: Union[str, List[str]] = None,
        prefix: str = "auto",
        arbitrary: Union[str, bool] = False,
        theme: Union[str, "Theme"] = "default",
        usage: Union[str, List[str]] = None,
    ) -> None:
        self.names: List[str] = always_list(names) if names else []
        self.desc: List[str] = (
            PARAMS_DEFAULT.desc
            if desc is None
            else always_list(desc, strip=False, split=False)
        )
        self._prog: str = Path(sys.argv[0]).name if prog is None else prog
        self.help_keys: List[str] = (
            PARAMS_DEFAULT.help_keys
            if help_keys is None
            else always_list(help_keys)
        )
        self.fullopt_keys: List[str] = (
            PARAMS_DEFAULT.fullopt_keys
            if fullopt_keys is None
            else always_list(fullopt_keys)
        )
        self.help_cmds: List[str] = (
            PARAMS_DEFAULT.help_cmds
            if help_cmds is None
            else always_list(help_cmds)
        )
        self.help_on_void: bool = (
            PARAMS_DEFAULT.help_on_void
            if help_on_void is None
            else help_on_void
        )
        self.usage = (
            None
            if usage is None
            else always_list(usage, strip=True, split="\n")
        )
        self.prefix = prefix
        self.arbitrary = arbitrary
        self.theme = theme

        self.params = OrderedDiot()
        self.commands = OrderedDiot()

        self.param_groups = OrderedDiot()
        self.command_groups = OrderedDiot()

        self.help_modifier = help_modifier

        self.assembler = HelpAssembler(self.prog, theme, help_callback)

        self.has_hidden = False
        super().__init__()

    @propertyDOCS
    def prog(self) -> str:
        """Get the program name"""
        return self._prog

    @prog.setter
    def prog(self, value: str):
        """Set the program name and update the help assembler

        Args:
            value: The new program name
        """
        self._prog = value
        self.assembler.console.meta.prog = value
        self.assembler.console.meta.highlighters.prog = ProgHighlighter(value)

    def name(self, which: str = "short") -> str:DOCS
        """Get the shortest/longest name of the parameter

        A name is ensured to be returned. It does not mean it is the real
        short/long name, but just the shortest/longest name among all the names

        Args:
            which: Whether get the shortest or longest name
                Could use `short` or `long` for short.

        Returns:
            The shortest/longest name of the parameter
        """
        if not self.names:
            return None
        return list(sorted(self.names, key=len))[0 if "short" in which else -1]

    def namestr(self, sep: str = ", ") -> str:DOCS
        """Get all names connected with a separator.

        Args:
            sep: The separator to connect the names

        Returns:
            the connected names
        """
        return sep.join(sorted(self.names, key=len))

    def get_param(self, name: str) -> "Param":DOCS
        """Get the parameter by name

        If the parameter is under a namespace, try to get it via the namespace

        Args:
            name: The name of the parameter to get (without prefix)

        Returns:
            The parameter, None if failed
        """
        if name is None:
            return None
        if "." not in name:
            return self.params.get(name)

        ns_param: "ParamNamespace" = self.params.get(name.split(".", 1)[0])
        if not ns_param:
            return None
        ret = ns_param.get_param(name)
        return None if ret is None or name not in ret.names else ret

    def get_command(self, name: str) -> "Params":DOCS
        """Get the command object

        Args:
            name: The name of the command to get

        Returns:
            The command object, None if failed.
        """
        return self.commands.get(name)

    def _set_param(self, param: "Param") -> None:
        """Set the parameter

        When a paraemeter's type is overwritten, we need to replace it with
        the new one in the pool (either self.params or the namespace parameter
        that holds it).
        """
        if param.namespaces(0):
            param.ns_param.push(param, -1)
        else:
            for name in param.names:
                self.params[name] = param

    def add_param(DOCS
        self,
        names: Union[str, List[str], "Param"],
        default: Any = None,
        type: Union[str, Type] = None,
        desc: Union[str, List[str]] = None,
        show: bool = None,
        required: bool = None,
        callback: Callable = None,
        group: str = None,
        force: bool = False,
        type_frozen: bool = None,
        argname_shorten: bool = None,
        complete_callback: Callable = None,
        **kwargs,
    ) -> "Param":
        """Add an argument

        Args:
            names: names of the argument or a parameter defined somewhere else
                For example, in case we want to reuse a parameter
                ```python
                param = cmd1.add_param('n,name')
                # reuse it:
                cmd2.add_param(param)
                # other arguments will be ignored, except force
                ```

            default: The default value for the argument
            type: The type of the argument
                Including single value type and complex one
                - Single value types:
                    auto, int, str, float, bool, count, py, json, reset
                - Complex value types:
                    list[<single value type>], ns
            desc: The description of the argument
                This will be finally compiled into a list if a string is given.
                The difference is, when displayed on help page, the string will
                be wrapped by textwrap automatically. However, each element in
                a given list will not be wrapped.
            show: Whether this should be shown on help page.
            required: Whether this argument is required from
                the command line
            callback: Callback to convert parsed values
            group: The group this parameter belongs to.
                Arguments will be grouped by this on the help page.
            force: Whether to force adding parameter if it exists
            type_frozen: Whether allow type overwritting from
                the commone line
            argname_shorten: Whether show shortened name for parameter
                under namespace parameter
            complete_callback: The callback for complete the values of the
                parameter
            **kwargs: Additional keyword arguments

        Raises:
            PyParamNameError: When parameter exists and force is false

        Return:
            Param: The added parameter
        """
        if isinstance(names, (str, list)):
            names: List[str] = always_list(names)
            if type is None:
                if POSITIONAL in names and default is None:
                    type = "list"
                else:
                    type = type_from_value(default)

            maintype, subtype = parse_type(
                type.__name__
                if callable(type)
                else PARAM_DEFAULT.type
                if type is None
                else type
            )
            show = PARAM_DEFAULT.show if show is None else show
            if not show:
                self.has_hidden = True

            param = PARAM_MAPPINGS[maintype](  # type: ignore
                names=names,
                default=PARAM_DEFAULT.default if default is None else default,
                desc=PARAM_DEFAULT.desc
                if desc is None
                else always_list(desc, strip=False, split=False),
                prefix=self.prefix,
                show=show,
                required=(
                    PARAM_DEFAULT.required if required is None else required
                ),
                subtype=subtype,
                type_frozen=(
                    PARAM_DEFAULT.type_frozen
                    if type_frozen is None
                    else type_frozen
                ),
                callback=callback,
                argname_shorten=(
                    PARAM_DEFAULT.argname_shorten
                    if argname_shorten is None
                    else argname_shorten
                ),
                complete_callback=complete_callback,
                **kwargs,
            )
        else:
            param = names.copy()

        if any(name in self.help_keys for name in param.names):
            param.is_help = True
        if any(name in self.fullopt_keys for name in param.names):
            param.is_help = param.is_full = True

        if param.namespaces(0):
            # add namespace parameter automatically
            if param.namespaces(0)[0] not in self.params:  # type: ignore
                self.add_param(param.namespaces(0), type="ns")  # type: ignore

            ns_param: "ParamNamespace" = self.params[
                param.namespaces(0)[0]
            ]  # type: ignore
            ns_param.push(param)

        else:
            for name in param.names:
                # check if parameter has been added
                if not force and (
                    (name in self.params and self.params[name] is not param)
                    or name in self.commands
                ):
                    raise PyParamNameError(
                        f"Argument {name!r} has already been added."
                    )
                self.params[name] = param

        group = group or param.default_group
        # leave the parameters here under namespace to have flexibility
        # assigning different groups
        groups: List["Param"] = self.param_groups.setdefault(group, [])

        # any parameter with param.names hasn't been added
        if not any(set(param.names) & set(prm.names) for prm in groups):
            groups.append(param)
        return param

    def add_command(DOCS
        self,
        names: Union["Params", str, List[str]],
        desc: Union[str, List[str]] = "No description",
        help_keys: Union[str, List[str]] = "__inherit__",
        help_cmds: Union[str, List[str]] = "__inherit__",
        help_on_void: Union[str, bool] = "__inherit__",
        help_callback: Callable = None,
        help_modifier: Callable = None,
        prefix: str = "__inherit__",
        arbitrary: Union[str, bool] = "__inherit__",
        theme: Union[str, rich.theme.Theme] = "__inherit__",
        usage: Union[str, List[str]] = None,
        group: str = None,
        force: bool = False,
    ) -> "Params":
        """Add a sub-command

        Args:
            names: list of names of this command
                It can also be a Params object that served as a subcommand
            desc: description of this command
            help_keys: help key for bring up help for this command
            help_cmds: help command for printing help for other
                sub-commands of this command
            help_on_void: whether printing help when no arguments passed
            help_callback: callback to manipulate help page
            prefix: prefix for arguments for this command
            arbitray: whether do arbitray Parsing
            theme: The theme of help page for this command
            usage: Usage for this command
            group: Group of this command
            force: Force adding when command exists already.

        Returns:
            The added command
        """
        command: "Params" = None
        if isinstance(names, Params):
            command = names
            command.prog = (
                f"{self.prog}{' [OPTIONS]' if self.params else ''} "
                f"{command.name('long')}"
            )
        else:
            names: List[str] = always_list(names)
            command: "Params" = Params(
                desc=desc,
                prog=(
                    f"{self.prog}{' [OPTIONS]' if self.params else ''} "
                    f"{sorted(names, key=len)[-1]}"
                ),
                help_keys=(
                    self.help_keys if help_keys == "__inherit__" else help_keys
                ),
                help_cmds=(
                    self.help_cmds if help_cmds == "__inherit__" else help_cmds
                ),
                help_on_void=(
                    self.help_on_void
                    if help_on_void == "__inherit__"
                    else help_on_void
                ),
                help_callback=help_callback,
                help_modifier=help_modifier,
                prefix=(self.prefix if prefix == "__inherit__" else prefix),
                arbitrary=(
                    self.arbitrary if arbitrary == "__inherit__" else arbitrary
                ),
                theme=(self.theme if theme == "__inherit__" else theme),
                usage=usage,
                names=names,
            )
        for cmd in command.names:
            # check if command has been added
            if not force and (cmd in self.params or cmd in self.commands):
                raise PyParamNameError(
                    f"Command {cmd!r} has already been added."
                )
            self.commands[cmd] = command

        group = group or "COMMANDS"
        groups: List["Params"] = self.command_groups.setdefault(group, [])

        if not any(set(command.names) & set(cmd.names) for cmd in groups):
            groups.append(command)
        return command

    def print_help(self, exit_code: int = 1, full: bool = False) -> None:DOCS
        """Print the help information and exit

        Args:
            exit_code: The exit code or False to not exit
        """
        self.assembler.assemble(self, full=full)
        self.assembler.printout()
        if exit_code is not False:
            sys.exit(exit_code)

    def values(DOCS
        self, namespace: Namespace = None, ignore_errors: bool = False
    ) -> Namespace:
        """Get a namespace of all parameter name => value pairs or attach them
        to the given namespace

        Args:
            namespace: The namespace for the values to attach to.

        Returns:
            the namespace with values of all parameter
                name-value pairs
        """
        ns_no_callback: Namespace = Namespace()
        for param_name, param in self.params.items():
            if param.is_help or param_name in ns_no_callback:
                continue
            try:
                value: Any = param.value
            except (PyParamTypeError, PyParamValueError) as pve:
                if not ignore_errors:
                    logger.error("%r: %s", param.namestr(), pve)
                    self.print_help()
            except Exception:  # pragma: no cover
                if not ignore_errors:
                    raise
            else:
                for name in param.names:
                    ns_no_callback[name] = value

        if namespace is None:
            namespace = Namespace()

        for param_name, param in self.params.items():
            if param.is_help or param_name in namespace:
                continue
            try:
                value: Any = param.apply_callback(ns_no_callback)
            except (PyParamTypeError, PyParamValueError) as pte:
                if not ignore_errors:  # pragma: no cover
                    logger.error("%r: %s", param.namestr(), pte)
                    self.print_help()
            except Exception:
                if not ignore_errors:
                    raise
            else:
                for name in param.names:
                    setattr(namespace, name, value)
        return namespace

    def parse(DOCS
        self, args: List[str] = None, ignore_errors: bool = False
    ) -> Namespace:
        """Parse the arguments from the command line

        Args:
            args: The arguments to parse
            ignore_errors: Whether to ignore errors.
                This is helpful to check a specific option or command,
                but ignore errors, such as required options not provided.

        Return:
            Namespace: The namespace of parsed arguments
        """
        # add help options here so that user can disable or
        # change it before parsing
        self.add_param(
            self.help_keys,
            type="bool",
            desc="Print help information for this command",
            force=True,
        )
        if self.has_hidden:
            self.add_param(
                self.fullopt_keys,
                type="bool",
                desc="Show full options for this command",
                force=True,
            )

        help_cmd: "Params" = None
        if self.commands:
            help_cmd = self.add_command(
                self.help_cmds, desc="Print help of sub-commands", force=True
            )
            help_cmd.add_param(
                POSITIONAL,
                type="choice",
                default="",
                desc=(
                    "Command name to print help for. "
                    "Available commands are: {}".format(
                        ", ".join(
                            cmd
                            for cmd in self.commands
                            if cmd not in self.help_cmds
                        )
                    )
                ),
                choices=list(self.commands),
            )

        if callable(self.help_modifier):
            self.help_modifier(self.get_param(self.help_keys[0]), help_cmd)

        if args is None:
            # enable completion only when we are trying to parse sys.argv
            if self.comp_shell:
                print("\n".join(self.complete()))
                sys.exit(0)
            args = sys.argv[1:]

        if not args and self.help_on_void:
            self.print_help()

        namespace: Namespace = Namespace()
        self._parse(args, namespace, ignore_errors)

        # # Allow command to be not provided.
        # if self.commands and not namespace.__command__:
        #     logger.error('No command given.')
        #     self.print_help()
        # run help subcommand
        if (
            namespace.__command__ in self.help_cmds
            and len(self.commands) > 1  # together with help command
        ):
            command_passed = namespace[namespace.__command__][POSITIONAL]
            if not command_passed:  # pragma: no cover
                self.print_help()
            elif command_passed not in self.commands:  # pragma: no cover
                logger.error("Unknown command: %r", command_passed)
                self.print_help()
            else:
                self.commands[command_passed].print_help()
        return namespace

    def _parse(
        self,
        args: List[str],
        namespace: Namespace,
        ignore_errors: bool,
    ) -> None:
        """Parse the arguments from the command line

        Args:
            args: The arguments to parse
            namespace: The namespace for parsed arguments to
                attach to.
        """
        logger.debug("Parsing %r", args)

        if not args:  # help_on_void = False
            self.values(namespace, ignore_errors)
            return

        prev_param: "Param" = None
        for i, arg in enumerate(args):
            logger.debug("- Parsing item %r", arg)
            # Match the arg with defined parameters
            # If arbitrary, non-existing parameters will be created on the fly
            # This means
            # 1. if param_name is None
            #    arg is not a parameter-like format (ie. -a, --arg)
            #    then param_value == arg
            # 2. if param_name is not None, arg is parameter-like
            #    With arbitrary = True, parameter will be created on the fly
            # 3. if arg is like --arg=1, then param_value 1 is pushed to param.
            param, param_name, param_type, param_value = self._match_param(arg)
            logger.debug("  Previous: %r", prev_param)
            logger.debug(
                "  Matched: %r, name=%s, type=%s, value=%r",
                param,
                param_name,
                param_type,
                param_value,
            )
            # as long as the help argument hit
            if (
                param_name in self.help_keys
                or param_name in self.fullopt_keys
                or (param and param.is_help)
            ):
                self.print_help(
                    full=param_name in self.fullopt_keys
                    or (param and param.is_full)
                )

            if param:
                if prev_param:
                    logger.debug("  Closing previous argument")
                    prev_param.close()
                prev_param = param

            elif prev_param:  # No param
                if param_name is not None:
                    if not ignore_errors:
                        logger.warning("Unknown argument: %r, skipped", arg)
                elif not prev_param.consume(param_value):
                    # If value cannot be consumed, let's see if it
                    # 1. hits a command
                    # 2. hits the start of positional arguments
                    prev_param.close()
                    prev_param, matched = self._match_command_or_positional(
                        prev_param,
                        param_value,
                        args[(i + 1) :],
                        namespace,
                        ignore_errors,
                    )
                    if matched == "command":
                        break
                    if matched == "positional":
                        continue
                    if param_value is not None and not ignore_errors:
                        logger.warning(
                            "Unknown value: %r, skipped", param_value
                        )
                else:
                    logger.debug(
                        "  Param %r consumes %r",
                        prev_param.namestr(),
                        param_value,
                    )
            else:  # neither
                prev_param, matched = self._match_command_or_positional(
                    prev_param,
                    param_value,
                    args[(i + 1) :],
                    namespace,
                    ignore_errors,
                )
                if matched == "command":
                    break
                if matched == "positional":
                    continue
                if param_value is not None and not ignore_errors:
                    logger.warning("Unknown value: %r, skipped", param_value)

        if prev_param:
            logger.debug("  Closing final argument: %r", prev_param.namestr())
            prev_param.close()

        self.values(namespace, ignore_errors)

    def _match_command_or_positional(
        self,
        prev_param: "Param",
        arg: str,
        rest_args: List[str],
        namespace: Namespace,
        ignore_errors: bool = False,
    ) -> Tuple["Param", str]:
        """Check if arg hits a command or a positional argument start

        Args:
            prev_param: The previous parameter
            arg: The current argument item
            rest_args: The remaining argument items

        Returns:
            tuple (Param, str):
                - A parameter if we create a new one here
                    (ie, a positional parameter)
                - 'command' when arg hits a command or 'positional' when it
                    hits the start of a positional argument. Otherwise, None.
        """
        if prev_param and prev_param.is_positional:
            logger.debug("  Hit positional argument")
            prev_param.push(arg)
            return prev_param, "positional"

        if arg not in self.commands:
            # any of the rest args matches is argument-like then
            # this should not hit the start of positional argument
            for rest_arg in rest_args:
                if self.prefix != "auto" and rest_arg.startswith(self.prefix):
                    break

                if self.prefix == "auto" and rest_arg[:1] == "-":
                    if len(rest_arg) <= 2 or (
                        rest_arg[:2] == "--" and len(rest_arg) > 3
                    ):
                        break
            else:
                logger.debug("  Hit start of positional argument")
                if self.arbitrary and POSITIONAL not in self.params:
                    self.add_param(POSITIONAL)
                if POSITIONAL in self.params:
                    self.params[POSITIONAL].hit = True
                    self.params[POSITIONAL].push(arg)
                    return self.params[POSITIONAL], "positional"

        if prev_param:
            prev_param.close()

        if self.arbitrary and arg not in self.commands:
            self.add_command(arg)

        if arg not in self.commands:
            return None, None

        logger.debug("* Hit command: %r", arg)
        command: "Params" = self.commands[arg]
        namespace.__command__ = arg
        parsed: Namespace = command.parse(rest_args, ignore_errors)
        for name in command.names:
            namespace[name] = parsed

        return None, "command"

    def _match_param(self, arg: str) -> Tuple["Param", str, str, str]:
        """Check if arg matches any predefined parameters. With
        arbitrary = True, parameters will be defined on the fly.

        And then do all the preparation for the matched parameter, including
        overwrite the type and push the attached value, such as '--arg=1'

        Args:
            arg: arg to check

        Returns:
            The matched parameter, parameter name,
                type and unpushed value if matched.
                Otherwise, None, param_name, param_type and arg itself.
        """
        param_name, param_type, param_value = parse_potential_argument(
            arg, self.prefix
        )
        # parse -arg as -a rg only applicable with prefix auto and -
        # When we didn't match any argument-like
        # with allow_attached=False
        # Or we matched but it is not defined
        name_with_attached: str = None
        if not param_type and self.prefix == "auto":
            # then -a1 will be put in param_value, as if `a1` is a name,
            # it should be --a1
            name_with_attached = (
                param_value
                if (
                    param_name is None
                    and param_value
                    and param_value[:1] == "-"
                    and param_value[1:2] != "-"
                )
                else None
            )

        elif not param_type and len(self.prefix) <= 1:
            # say prefix = '+'
            # then `a1` for `+a1` will be put as param_name, since
            # there is no restriction on name length
            name_with_attached = (
                self.prefix + param_name
                if param_name and param_name[:1] != self.prefix
                else None
            )

        # we cannot find a parameter with param_name
        # check if there is any value attached
        if name_with_attached and not self.get_param(param_name):
            param_name2, param_type2, param_value2 = parse_potential_argument(
                name_with_attached, self.prefix, allow_attached=True
            )
            # Use them only if we found a param_name2 and
            # arbitrary: not previous param_name found
            # otherwise: parameter with param_name2 exists
            if param_name2 is not None and (
                (self.arbitrary and param_name is None)
                or self.get_param(param_name2)
            ):
                param_name, param_type, param_value = (
                    param_name2,
                    param_type2,
                    param_value2,
                )

        # create the parameter for arbitrary
        if (
            self.arbitrary
            and param_name is not None
            and not self.get_param(param_name)
        ):
            self.add_param(param_name, type=param_type)

        param: "Param" = self.get_param(param_name)
        if not param:
            return None, param_name, param_type, param_value

        param_maybe_overwritten: "Param" = param.overwrite_type(param_type)
        if param_maybe_overwritten is not param:
            self._set_param(param_maybe_overwritten)
            param = param_maybe_overwritten

        param.hit = True
        if param_value is not None:
            param.push(param_value)
        return param, param_name, param_type, param_value

    def _all_params(self, show_only=False) -> List["Param"]:
        """All parameters under this command

        self.params don't have all of them, since namespaced ones are under
        namespace paramters
        """
        ret: List["Param"] = []
        ret_append: Callable = ret.append
        for param in self.params.values():
            if param.type == "ns":
                if (not show_only or param.show) and param not in ret:
                    ret.append(param)
                ret.extend(param.decendents(show_only))

            elif show_only and not param.show:
                continue
            elif param not in ret:
                ret_append(param)
        return ret

    def _to_dict_params(self) -> Diot:
        """Load all parameters into a dictionary"""
        ret = Diot()
        param_groups = {}
        for group, param_list in self.param_groups.items():
            for param in param_list:
                param_groups[param.names[0]] = group

        for param in self._all_params():
            param_name: str = (
                "POSITIONAL"
                if param.names[0] == POSITIONAL
                else param.names[0]
            )
            ret[param_name] = Diot()
            param_dict: Diot = ret[param_name]
            if len(param.names) > 1:
                param_dict.aliases = [
                    "POSITIONAL" if name == POSITIONAL else name
                    for name in param.names[1:]
                ]
            if param.default != PARAM_DEFAULT.default:
                param_dict.default = param.default
            if param.typestr() != PARAM_DEFAULT.type:
                param_dict.type = param.typestr()
            if param.desc != PARAM_DEFAULT.desc:
                param_dict.desc = param.desc
            if param.show != PARAM_DEFAULT.show:
                param_dict.show = param.show
            if param.required != PARAM_DEFAULT.required:
                param_dict.required = param.required
            if param.type_frozen != PARAM_DEFAULT.type_frozen:
                param_dict.type_frozen = param.type_frozen
            if param.argname_shorten != PARAM_DEFAULT.argname_shorten:
                param_dict.argname_shorten = param.argname_shorten
            param_dict.group = group = param_groups[param.names[0]]
            param_dict |= param._kwargs

        return ret

    def _to_dict_commands(self) -> Diot:
        """Load all commands into a dictionary"""
        ret = Diot()
        command_groups = {}
        for group, command_list in self.command_groups.items():
            for command in command_list:
                for name in command.names:
                    command_groups[name] = group

        for command_name, command in self.commands.items():
            if any(name in ret for name in command.names):
                continue
            ret[command_name] = Diot()
            cmd_dict: Diot = ret[command_name]
            if len(command.names) > 1:
                cmd_dict.aliases = [
                    name for name in command.names if name != command_name
                ]
            if command.desc != PARAMS_DEFAULT.desc:
                cmd_dict.desc = command.desc
            if command.help_keys != PARAMS_DEFAULT.help_keys:
                cmd_dict.help_keys = command.help_keys
            if command.help_cmds != PARAMS_DEFAULT.help_cmds:
                cmd_dict.help_cmds = command.help_cmds
            if command.help_on_void != PARAMS_DEFAULT.help_on_void:
                cmd_dict.help_on_void = command.help_on_void
            if command.prefix != PARAMS_DEFAULT.prefix:
                cmd_dict.prefix = command.prefix
            if command.arbitrary != PARAMS_DEFAULT.arbitrary:
                cmd_dict.arbitrary = command.arbitrary
            if command.theme != PARAMS_DEFAULT.theme:
                cmd_dict.theme = command.theme
            if command.usage != PARAMS_DEFAULT.usage:
                cmd_dict.usage = command.usage
            cmd_dict.group = command_groups[command_name]
            cmd_dict |= command.to_dict()

        return ret

    def to_dict(self) -> Diot:DOCS
        """Save the parameters/commands to file.

        This is helpful if the parameters/commands take time to load. Once can
        cache this to a file, and load it from it using `from_file`.

        Returns:
            The complied Diot of parameters and commands
        """
        # load all parameters and commands
        return Diot(
            params=self._to_dict_params(), commands=self._to_dict_commands()
        )

    def to_file(self, path: Union[str, Path], cfgtype: str = None) -> None:DOCS
        """Save the parameters/commands to file.

        This is helpful if the parameters/commands take time to load. Once can
        cache this to a file, and load it from it using `from_file`.

        Args:
            path: The path to the file
            cfgtype: The type of the file
                If not given, will inferred from the suffix of the path
                Supports one of yaml, toml and json
        """
        loaded: Diot = self.to_dict()
        if not isinstance(path, Path):
            path = Path(path)
        if not cfgtype:
            if path.suffix in (".yml", ".yaml"):
                cfgtype = "yaml"
            elif path.suffix == ".toml":
                cfgtype = "toml"
            elif path.suffix == ".json":
                cfgtype = "json"
            else:
                cfgtype = path.suffix
        if cfgtype not in ("yaml", "json", "toml"):
            raise ValueError("File type not supported: %s" % cfgtype)

        if cfgtype == "yaml":
            loaded.to_yaml(path)
        elif cfgtype == "json":
            loaded.to_json(path)
        else:
            loaded.to_toml(path)

    def from_file(DOCS
        self,
        filename: Union[str, PathLike, dict],
        show: bool = True,
        force: bool = False,
    ) -> None:
        """Load parameters from file

        We support 2 types for format to load the parameters.

        - express way, which has some limitations:
            1. no command definition;
            2. no namespace parameters;
        ```toml
        arg = 1 # default value
        "arg$desc" = "An argument" # description
        # other attributes
        ```

        - full specification
        ```toml
        [params.arg]
        default = 1
        desc = "An argument"
        [commands.command]
        desc = "A subcommand"

          [commands.command.params.arg]
          default = 2
          desc = "An argument for command"
        ```

        Args:
            filename: path to the file
            filetype: The type of the file. If None, will infer from the
                filename. Supported types: ini, cfg, conf, config, yml, yaml,
                json, env, osenv, toml
            show: The default show value for parameters in the file
            force: Whether to force adding params/commands
        """
        config = Config.load(filename)
        self.from_dict(config, show=show, force=force)

    def _from_dict_with_sections(
        self,
        dict_obj: Dict[str, Dict[str, dict]],
        show: bool = True,
        force: bool = False,
    ) -> None:
        """Load from the dict with 'params' and/or 'commands' sections"""

        param_section: Dict[str, dict] = dict_obj.get("params", {})
        # Type: str, dict
        for param_name, param_attrs in param_section.items():
            param_attrs.setdefault("show", show)
            names: List[str] = always_list(param_attrs.pop("aliases", []))
            names.insert(0, param_name)
            self.add_param(
                [
                    POSITIONAL if name == "POSITIONAL" else name
                    for name in names
                ],
                **param_attrs,
                force=force,
            )

        command_section: Dict[str, dict] = dict_obj.get("commands", {})
        for command_name, command_attrs in command_section.items():
            names: List[str] = always_list(command_attrs.pop("aliases", []))
            names.insert(0, command_name)
            param_section: Dict[str, dict] = command_attrs.pop("params", {})
            subcmd_section: Dict[str, dict] = command_attrs.pop("commands", {})
            command: "Params" = self.add_command(
                names, **command_attrs, force=force
            )
            command.from_dict(
                {"params": param_section, "commands": subcmd_section}, show
            )

    def _express_to_full_dict(
        self,
        expr_dict: dict,
        ns_key: str = None,
    ) -> dict:
        """Convert express dict to full dict specification"""
        full_dict = {
            (
                (ns_key + "." if ns_key else "")
                + (key if "." not in key else key.split(".", 1)[0])
            ): {}
            for key in expr_dict
        }
        for key, val in expr_dict.items():
            nskey = f"{ns_key}.{key}" if ns_key else key
            if nskey in full_dict:
                param = self.get_param(nskey)
                if isinstance(param, ParamNamespace):
                    del full_dict[nskey]
                    if not isinstance(val, dict):
                        raise ValueError(
                            f"Value for namespace parameter `{nskey}` "
                            "must be a dict."
                        )

                    full_dict.update(
                        self._express_to_full_dict(val, nskey)
                    )
                else:
                    full_dict[nskey]["default"] = val

            else:
                param_name, _, param_attr = key.rpartition(".")
                full_dict[param_name][param_attr] = val

        return full_dict

    def from_dict(DOCS
        self,
        dict_obj: dict,
        show: bool = True,
        force: bool = False,
    ):
        """Load parameters from python dict

        Args:
            dict_obj: A python dictionary to load parameters from
            show: The default show value for the parameters in the
                dictionary
            force: Whether to force adding params/commands
        """
        if "params" not in dict_obj and "commands" not in dict_obj:
            # express way
            # scan and create dict for all params
            dict_obj = self._express_to_full_dict(dict_obj)
            dict_obj = {"params": dict_obj}

        self._from_dict_with_sections(dict_obj, show, force)

    def from_arg(DOCS
        self,
        names: Union[str, List[str], "ParamPath"],
        desc: Union[str, List[str]] = "The configuration file.",
        group: str = None,
        default: Union[str, Path] = None,
        show: bool = True,
        force: bool = False,
        args: List[str] = None,
    ):
        """Load parameters from the file specified by naargument
        from command line

        This will load the parameters from the file given by the argument,
        ignoring other arguments from the command line. One can overwrite
        some of them afterwards, and do the parsing finally.

        Args:
            names: The names of the parameter or
                the parameter itself. If it is the parameter, other
                arguments are ignored
            desc: The description of the parameter
            group: The group of the parameter
            default: The default value of the file path
            show: Whether those parameters should show up in the help page
            force: Whether to force adding the parameters/commands
            args: The list of items to parse, otherwise
                parse sys.argv[1:]
        """
        if isinstance(names, (str, list)):
            param: "Param" = self.add_param(
                names, desc=desc, group=group, type="path", default=default
            )
        else:
            param: "Param" = names
            self._set_param(names)

        prefixed_names: List[str] = [
            param._prefix_name(name) for name in param.names
        ]
        args: List[str] = sys.argv[1:] if args is None else args
        args_with_the_arg: List[str] = []
        for i, arg in enumerate(args):
            if arg in prefixed_names:
                args_with_the_arg.append(arg)
                if i < len(args) - 1:
                    args_with_the_arg.append(args[i + 1])
                break
        filepath: Union[str, Path] = (
            args_with_the_arg[1] if len(args_with_the_arg) > 1 else param.value
        )
        if filepath:
            self.from_file(filepath, show=show, force=force)

    def __repr__(self):
        return "<Params(%s) @ %s>" % (",".join(self.names), id(self))

    def copy(self, deep=False) -> "Params":DOCS
        """Copy a Params object

        Args:
            deep: Whether to copy the parameters and commands deeply

        Returns:
            The copied params object
        """
        copied: "Params" = Params(
            names=self.names[:],
            desc=self.desc[:],
            prog=self.prog,
            help_keys=self.help_keys[:],
            help_cmds=self.help_cmds[:],
            help_on_void=self.help_on_void,
            help_callback=None,
            help_modifier=self.help_modifier,
            prefix=self.prefix,
            arbitrary=self.arbitrary,
            theme=self.theme,
            usage=self.usage and self.usage[:],
        )
        copied.assembler = self.assembler

        if not deep:
            copied.params = self.params.copy()
            copied.commands = self.commands.copy()

            copied.param_groups = self.param_groups.copy()
            copied.command_groups = self.command_groups.copy()
        else:
            copied.params = OrderedDiot()
            copied.commands = OrderedDiot()

            copied.param_groups = OrderedDiot()
            copied.command_groups = OrderedDiot()

            for param in self.params.values():
                if any(name in copied.params for name in param.names):
                    continue
                param_copy = param.copy()
                for name in param.names:
                    copied.params[name] = param_copy

            for command in self.commands.values():
                if any(name in copied.commands for name in command.names):
                    continue
                command_copy = command.copy(deep=deep)
                for name in command.names:
                    copied.commands[name] = command_copy

            for group, param_list in self.param_groups.items():
                self.param_groups[group] = [
                    copied.get_param(param.names[0]) for param in param_list
                ]
            for group, cmd_list in self.command_groups.items():
                self.command_groups[group] = [
                    copied.commands[cmd.names[0]] for cmd in cmd_list
                ]

        return copied