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 02deb38
Show file tree
Hide file tree
Showing 6 changed files with 140 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.")

Check warning on line 86 in craft_cli/dispatcher.py

View check run for this annotation

Codecov / codecov/patch

craft_cli/dispatcher.py#L86

Added line #L86 was not covered by tests
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
29 changes: 29 additions & 0 deletions craft_cli/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2024 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""Utility functions for craft_cli."""
from typing import Sequence


def humanise_list(values: Sequence[str], conjunction: str = "and") -> str:
"""Convert a collection of values to a string that lists the values.
:param values: The values to turn into a list
:param conjunction: The conjunction to use between the last two items
:returns: A string containing the list phrase ready to insert into a sentence.
"""
if not values:
return ""
start = ", ".join(values[:-1])
return f"{start} {conjunction} {values[-1]}"
1 change: 1 addition & 0 deletions docs/.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ RTD
subdirectories
subtree
subfolders
utils
UI
VM
YAML
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ emitter = "craft_cli.pytest_plugin"
[project.optional-dependencies]
dev = [
"coverage[toml]==7.3.2",
"hypothesis==6.92.9",
"pytest==7.4.3",
"pytest-check==2.2.4",
"pytest-cov==4.1.0",
"pytest-mock==3.12.0",
"pytest-subprocess"
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
55 changes: 55 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright 2024 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""Unit tests for utility functions."""
import re

import pytest
import pytest_check
from hypothesis import given, strategies

from craft_cli import utils


@pytest.mark.parametrize(
"values",
[
[],
["one-thing"],
["two", "things"],
],
)
@pytest.mark.parametrize("conjunction", ["and", "or", "but not"])
def test_humanise_list_success(values, conjunction):
actual = utils.humanise_list(values, conjunction)

pytest_check.equal(actual.count(","), max((len(values) - 2, 0)))
with pytest_check.check:
assert actual == "" or conjunction in actual
for value in values:
pytest_check.is_in(value, actual)


@given(
values=strategies.lists(strategies.text()),
conjunction=strategies.text(),
)
def test_humanise_list_fuzzy(values, conjunction):
actual = utils.humanise_list(values, conjunction)

pytest_check.greater_equal(actual.count(","), max((len(values) - 2, 0)))
with pytest_check.check:
assert actual == "" or conjunction in actual
for value in values:
pytest_check.is_in(value, actual)

0 comments on commit 02deb38

Please sign in to comment.