Skip to content

Commit 6011c94

Browse files
committed
feat(dispatcher): constrained global arguments
Fixes #219
1 parent 3a63abd commit 6011c94

File tree

5 files changed

+139
-22
lines changed

5 files changed

+139
-22
lines changed

craft_cli/dispatcher.py

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
from __future__ import annotations
1919

2020
import argparse
21+
import dataclasses
2122
import difflib
22-
from typing import Any, Literal, NamedTuple, NoReturn, Optional, Sequence
23+
from typing import Any, Callable, Literal, NamedTuple, NoReturn, Optional, Sequence
2324

24-
from craft_cli import EmitterMode, emit
25+
from craft_cli import EmitterMode, emit, utils
2526
from craft_cli.errors import ArgumentParsingError, ProvideHelpException
2627
from craft_cli.helptexts import HelpBuilder, OutputFormat
2728

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

4546

46-
class GlobalArgument(NamedTuple):
47+
@dataclasses.dataclass
48+
class GlobalArgument:
4749
"""Definition of a global argument to be handled by the Dispatcher."""
4850

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

69+
choices: Sequence[str] | None = dataclasses.field(default=None)
70+
"""Valid choices for this option."""
71+
72+
validator: Callable[[str], Any] | None = dataclasses.field(default=None)
73+
"""A validator callable that converts the option input to the correct value.
74+
75+
The validator is called when parsing the argument. If it raises an exception, the
76+
exception message will be used as part of the usage output. Otherwise, the return
77+
value will be used as the content of this option.
78+
"""
79+
80+
case_sensitive: bool = True
81+
"""Whether the choices are case sensitive. Only used if choices are set."""
82+
83+
def __post_init__(self) -> None:
84+
if self.type == "flag":
85+
if self.choices is not None or self.validator is not None:
86+
raise TypeError("A flag argument cannot have choices or a validator.")
87+
elif self.choices and not self.case_sensitive:
88+
self.choices = [choice.lower() for choice in self.choices]
89+
6790

6891
_DEFAULT_GLOBAL_ARGS = [
6992
GlobalArgument(
@@ -93,6 +116,9 @@ class GlobalArgument(NamedTuple):
93116
None,
94117
"--verbosity",
95118
"Set the verbosity level to 'quiet', 'brief', 'verbose', 'debug' or 'trace'",
119+
choices=[mode.name.lower() for mode in EmitterMode],
120+
validator=lambda mode: EmitterMode[mode.upper()],
121+
case_sensitive=False,
96122
),
97123
]
98124

@@ -397,20 +423,32 @@ def _parse_options( # noqa: PLR0912 (too many branches)
397423
arg = arg_per_option[sysarg]
398424
if arg.type == "flag":
399425
global_args[arg.name] = True
400-
else:
401-
try:
402-
global_args[arg.name] = next(sysargs_it)
403-
except StopIteration:
404-
msg = f"The {arg.name!r} option expects one argument."
405-
raise self._build_usage_exc(msg) # noqa: TRY200 (use 'raise from')
426+
continue
427+
option = sysarg
428+
try:
429+
value = next(sysargs_it)
430+
except StopIteration:
431+
msg = f"The {arg.name!r} option expects one argument."
432+
raise self._build_usage_exc(msg) # noqa: TRY200 (use 'raise from')
406433
elif sysarg.startswith(tuple(options_with_equal)):
407434
option, value = sysarg.split("=", 1)
408-
arg = arg_per_option[option]
409-
if not value:
410-
raise self._build_usage_exc(f"The {arg.name!r} option expects one argument.")
411-
global_args[arg.name] = value
412435
else:
413436
filtered_sysargs.append(sysarg)
437+
continue
438+
arg = arg_per_option[option]
439+
if not value:
440+
raise self._build_usage_exc(f"The {arg.name!r} option expects one argument.")
441+
if arg.choices is not None:
442+
if not arg.case_sensitive:
443+
value = value.lower()
444+
if value not in arg.choices:
445+
choices = utils.humanise_list([f"'{choice}'" for choice in arg.choices])
446+
raise self._build_usage_exc(
447+
f"Bad {arg.name} {value!r}; valid values are {choices}."
448+
)
449+
450+
validator = arg.validator or str
451+
global_args[arg.name] = validator(value)
414452
return global_args, filtered_sysargs
415453

416454
def pre_parse_args(self, sysargs: list[str]) -> dict[str, Any]:
@@ -436,14 +474,7 @@ def pre_parse_args(self, sysargs: list[str]) -> dict[str, Any]:
436474
elif global_args["verbose"]:
437475
emit.set_mode(EmitterMode.VERBOSE)
438476
elif global_args["verbosity"]:
439-
try:
440-
verbosity_level = EmitterMode[global_args["verbosity"].upper()]
441-
except KeyError:
442-
raise self._build_usage_exc( # noqa: TRY200 (use 'raise from')
443-
"Bad verbosity level; valid values are "
444-
"'quiet', 'brief', 'verbose', 'debug' and 'trace'."
445-
)
446-
emit.set_mode(verbosity_level)
477+
emit.set_mode(global_args["verbosity"])
447478
emit.trace(f"Raw pre-parsed sysargs: args={global_args} filtered={filtered_sysargs}")
448479

449480
# handle requested help through -h/--help options

craft_cli/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2024 Canonical Ltd.
2+
#
3+
# This program is free software; you can redistribute it and/or
4+
# modify it under the terms of the GNU Lesser General Public
5+
# License version 3 as published by the Free Software Foundation.
6+
#
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
10+
# Lesser General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU Lesser General Public License
13+
# along with this program; if not, write to the Free Software Foundation,
14+
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
15+
"""Utility functions for craft_cli."""
16+
from collections.abc import Sequence
17+
18+
19+
def humanise_list(values: Sequence[str], conjunction: str = "and") -> str:
20+
"""Convert a collection of values to a string that lists the values.
21+
22+
:param values: The values to turn into a list
23+
:param conjunction: The conjunction to use between the last two items
24+
:returns: A string containing the list phrase ready to insert into a sentence.
25+
"""
26+
if not values:
27+
return ""
28+
start = ", ".join(values[:-1])
29+
return f"{start} {conjunction} {values[-1]}"

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ emitter = "craft_cli.pytest_plugin"
4444
[project.optional-dependencies]
4545
dev = [
4646
"coverage[toml]==7.3.2",
47+
"hypothesis==6.92.9",
4748
"pytest==7.4.3",
49+
"pytest-check==2.2.4",
4850
"pytest-cov==4.1.0",
4951
"pytest-mock==3.12.0",
5052
"pytest-subprocess"

tests/unit/test_dispatcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ def test_dispatcher_generic_setup_verbosity_levels_wrong():
322322
Usage: appname [options] command [args]...
323323
Try 'appname -h' for help.
324324
325-
Error: Bad verbosity level; valid values are 'quiet', 'brief', 'verbose', 'debug' and 'trace'.
325+
Error: Bad verbosity 'yelling'; valid values are 'quiet', 'brief', 'verbose', 'debug' and 'trace'.
326326
"""
327327
)
328328

tests/unit/test_utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2024 Canonical Ltd.
2+
#
3+
# This program is free software; you can redistribute it and/or
4+
# modify it under the terms of the GNU Lesser General Public
5+
# License version 3 as published by the Free Software Foundation.
6+
#
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
10+
# Lesser General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU Lesser General Public License
13+
# along with this program; if not, write to the Free Software Foundation,
14+
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
15+
"""Unit tests for utility functions."""
16+
import re
17+
18+
import pytest
19+
import pytest_check
20+
from hypothesis import given, strategies
21+
22+
from craft_cli import utils
23+
24+
25+
@pytest.mark.parametrize(
26+
"values",
27+
[
28+
[],
29+
["one-thing"],
30+
["two", "things"],
31+
],
32+
)
33+
@pytest.mark.parametrize("conjunction", ["and", "or", "but not"])
34+
def test_humanise_list_success(values, conjunction):
35+
actual = utils.humanise_list(values, conjunction)
36+
37+
pytest_check.equal(actual.count(","), max((len(values) - 2, 0)))
38+
with pytest_check.check:
39+
assert actual == "" or conjunction in actual
40+
for value in values:
41+
pytest_check.is_in(value, actual)
42+
43+
44+
@given(
45+
values=strategies.lists(strategies.text()),
46+
conjunction=strategies.text(),
47+
)
48+
def test_humanise_list_fuzzy(values, conjunction):
49+
actual = utils.humanise_list(values, conjunction)
50+
51+
pytest_check.greater_equal(actual.count(","), max((len(values) - 2, 0)))
52+
with pytest_check.check:
53+
assert actual == "" or conjunction in actual
54+
for value in values:
55+
pytest_check.is_in(value, actual)

0 commit comments

Comments
 (0)