Skip to content

Commit

Permalink
feat(dispatcher): constrained global arguments
Browse files Browse the repository at this point in the history
Fixes #219
  • Loading branch information
lengau committed Jan 12, 2024
1 parent 3a63abd commit b5c1d4c
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 22 deletions.
73 changes: 52 additions & 21 deletions craft_cli/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
from __future__ import annotations

import argparse
import dataclasses
import difflib
from typing import Any, Literal, NamedTuple, NoReturn, Optional, Sequence
from typing import Any, Callable, Literal, NamedTuple, NoReturn, Optional, Sequence

from craft_cli import EmitterMode, emit
from craft_cli import EmitterMode, emit, utils
from craft_cli.errors import ArgumentParsingError, ProvideHelpException
from craft_cli.helptexts import HelpBuilder, OutputFormat

Expand All @@ -43,7 +44,8 @@ class CommandGroup(NamedTuple):
"""Whether the commands in this group are already in the correct order (defaults to False)."""


class GlobalArgument(NamedTuple):
@dataclasses.dataclass
class GlobalArgument:
"""Definition of a global argument to be handled by the Dispatcher."""

name: str
Expand All @@ -64,6 +66,27 @@ class GlobalArgument(NamedTuple):
help_message: str
"""the one-line text that describes the argument, for building the help texts."""

choices: Sequence[str] | None = dataclasses.field(default=None)
"""Valid choices for this option."""

validator: Callable[[str], Any] | None = dataclasses.field(default=None)
"""A validator callable that converts the option input to the correct value.
The validator is called when parsing the argument. If it raises an exception, the
exception message will be used as part of the usage output. Otherwise, the return
value will be used as the content of this option.
"""

case_sensitive: bool = True
"""Whether the choices are case sensitive. Only used if choices are set."""

def __post_init__(self) -> None:
if self.type == "flag":
if self.choices is not None or self.validator is not None:
raise TypeError("A flag argument cannot have choices or a validator.")
elif self.choices and not self.case_sensitive:
self.choices = [choice.lower() for choice in self.choices]


_DEFAULT_GLOBAL_ARGS = [
GlobalArgument(
Expand Down Expand Up @@ -93,6 +116,9 @@ class GlobalArgument(NamedTuple):
None,
"--verbosity",
"Set the verbosity level to 'quiet', 'brief', 'verbose', 'debug' or 'trace'",
choices=[mode.name.lower() for mode in EmitterMode],
validator=lambda mode: EmitterMode[mode.upper()],
case_sensitive=False,
),
]

Expand Down Expand Up @@ -397,20 +423,32 @@ def _parse_options( # noqa: PLR0912 (too many branches)
arg = arg_per_option[sysarg]
if arg.type == "flag":
global_args[arg.name] = True
else:
try:
global_args[arg.name] = next(sysargs_it)
except StopIteration:
msg = f"The {arg.name!r} option expects one argument."
raise self._build_usage_exc(msg) # noqa: TRY200 (use 'raise from')
continue
option = sysarg
try:
value = next(sysargs_it)
except StopIteration:
msg = f"The {arg.name!r} option expects one argument."
raise self._build_usage_exc(msg) # noqa: TRY200 (use 'raise from')
elif sysarg.startswith(tuple(options_with_equal)):
option, value = sysarg.split("=", 1)
arg = arg_per_option[option]
if not value:
raise self._build_usage_exc(f"The {arg.name!r} option expects one argument.")
global_args[arg.name] = value
else:
filtered_sysargs.append(sysarg)
continue
arg = arg_per_option[option]
if not value:
raise self._build_usage_exc(f"The {arg.name!r} option expects one argument.")
if arg.choices is not None:
if not arg.case_sensitive:
value = value.lower()
if value not in arg.choices:
choices = utils.humanise_list([f"'{choice}'" for choice in arg.choices])
raise self._build_usage_exc(
f"Bad {arg.name} {value!r}; valid values are {choices}."
)

validator = arg.validator or str
global_args[arg.name] = validator(value)
return global_args, filtered_sysargs

def pre_parse_args(self, sysargs: list[str]) -> dict[str, Any]:
Expand All @@ -436,14 +474,7 @@ def pre_parse_args(self, sysargs: list[str]) -> dict[str, Any]:
elif global_args["verbose"]:
emit.set_mode(EmitterMode.VERBOSE)
elif global_args["verbosity"]:
try:
verbosity_level = EmitterMode[global_args["verbosity"].upper()]
except KeyError:
raise self._build_usage_exc( # noqa: TRY200 (use 'raise from')
"Bad verbosity level; valid values are "
"'quiet', 'brief', 'verbose', 'debug' and 'trace'."
)
emit.set_mode(verbosity_level)
emit.set_mode(global_args["verbosity"])
emit.trace(f"Raw pre-parsed sysargs: args={global_args} filtered={filtered_sysargs}")

# handle requested help through -h/--help options
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ def test_dispatcher_generic_setup_verbosity_levels_wrong():
Usage: appname [options] command [args]...
Try 'appname -h' for help.
Error: Bad verbosity level; valid values are 'quiet', 'brief', 'verbose', 'debug' and 'trace'.
Error: Bad verbosity 'yelling'; valid values are 'quiet', 'brief', 'verbose', 'debug' and 'trace'.
"""
)

Expand Down

0 comments on commit b5c1d4c

Please sign in to comment.