Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add choices to arguments and options #435

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/cleo/descriptors/text_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ def _describe_argument(self, argument: Argument, **options: Any) -> None:
else:
default = ""

if argument.choices:
choices = (
f"<comment> {{{self._format_choices(argument.choices)}}}</comment>"
)
else:
choices = ""

total_width = options.get("total_width", len(argument.name))

spacing_width = total_width - len(argument.name)
Expand All @@ -41,7 +48,7 @@ def _describe_argument(self, argument: Argument, **options: Any) -> None:
)
self._write(
f" <c1>{argument.name}</c1> {' ' * spacing_width}"
f"{sub_argument_description}{default}"
f"{sub_argument_description}{default}{choices}"
)

def _describe_option(self, option: Option, **options: Any) -> None:
Expand Down Expand Up @@ -80,11 +87,17 @@ def _describe_option(self, option: Option, **options: Any) -> None:
are_multiple_values_allowed = (
"<comment> (multiple values allowed)</comment>" if option.is_list() else ""
)
are_choices_allowed = (
f"<comment> {{{', '.join(option.choices)}}}</comment>"
if option.choices
else ""
)
self._write(
f" <c1>{synopsis}</c1> "
f"{' ' * spacing_width}{sub_option_description}"
f"{default}"
f"{are_multiple_values_allowed}"
f"{are_choices_allowed}"
)

def _describe_definition(self, definition: Definition, **options: Any) -> None:
Expand Down Expand Up @@ -236,6 +249,9 @@ def _format_default_value(self, default: Any) -> str:

return json.dumps(default).replace("\\\\", "\\")

def _format_choices(self, choices: list[str]) -> str:
return ", ".join(choices)

def _calculate_total_width_for_options(self, options: list[Option]) -> int:
total_width = 0

Expand Down Expand Up @@ -271,6 +287,6 @@ def _get_column_width(self, commands: Sequence[Command | str]) -> int:

def _get_command_aliases_text(self, command: Command) -> str:
if aliases := command.aliases:
return f"[{ '|'.join(aliases) }] "
return f"[{'|'.join(aliases)}] "

return ""
4 changes: 4 additions & 0 deletions src/cleo/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ def argument(
optional: bool = False,
multiple: bool = False,
default: Any | None = None,
choices: list[str] | None = None,
) -> Argument:
return Argument(
name,
required=not optional,
is_list=multiple,
description=description,
default=default,
choices=choices,
)


Expand All @@ -30,6 +32,7 @@ def option(
value_required: bool = True,
multiple: bool = False,
default: Any | None = None,
choices: list[str] | None = None,
) -> Option:
return Option(
long_name,
Expand All @@ -39,4 +42,5 @@ def option(
is_list=multiple,
description=description,
default=default,
choices=choices,
)
16 changes: 15 additions & 1 deletion src/cleo/io/inputs/argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ def __init__(
is_list: bool = False,
description: str | None = None,
default: Any | None = None,
choices: list[str] | None = None,
) -> None:
self._name = name
self._required = required
self._is_list = is_list
self._description = description or ""
self._default: str | list[str] | None = None
self._choices = choices

self.set_default(default)

Expand All @@ -34,6 +36,10 @@ def name(self) -> str:
def default(self) -> str | list[str] | None:
return self._default

@property
def choices(self) -> list[str] | None:
return self._choices

@property
def description(self) -> str:
return self._description
Expand All @@ -44,7 +50,14 @@ def is_required(self) -> bool:
def is_list(self) -> bool:
return self._is_list

@property
def has_choices(self) -> bool:
return bool(self._choices)

def set_default(self, default: Any | None = None) -> None:
if self._choices and default is not None and default not in self._choices:
raise CleoLogicError("A default value must be in choices")

if self._required and default is not None:
raise CleoLogicError("Cannot set a default value for required arguments")

Expand All @@ -64,5 +77,6 @@ def __repr__(self) -> str:
f"required={self._required}, "
f"is_list={self._is_list}, "
f"description={self._description!r}, "
f"default={self._default!r})"
f"default={self._default!r}), "
f"choices={self._choices!r})"
)
13 changes: 13 additions & 0 deletions src/cleo/io/inputs/argv_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ def _parse_argument(self, token: str) -> None:
# If the input is expecting another argument, add it
if self._definition.has_argument(next_argument):
argument = self._definition.argument(next_argument)
if argument.has_choices and token not in argument.choices:
choices = ['"' + choice + '"' for choice in argument.choices]
raise CleoRuntimeError(
f'Invalid value for the "{argument.name}" argument: "{token}" (choose from {", ".join(choices)})'
)

self._arguments[argument.name] = [token] if argument.is_list() else token
# If the last argument is a list, append the token to it
elif (
Expand Down Expand Up @@ -292,3 +298,10 @@ def _add_long_option(self, name: str, value: Any) -> None:
self._options[name].append(value)
else:
self._options[name] = value

if option.choices and value not in option.choices:
choices = ['"' + choice + '"' for choice in option.choices]
raise CleoRuntimeError(
f'Invalid value for the "--{name}" option: "{value}" '
f'(choose from {", ".join(choices)})'
)
12 changes: 12 additions & 0 deletions src/cleo/io/inputs/option.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def __init__(
is_list: bool = False,
description: str | None = None,
default: Any | None = None,
choices: list[str] | None = None,
) -> None:
if name.startswith("--"):
name = name[2:]
Expand All @@ -43,10 +44,17 @@ def __init__(
self._is_list = is_list
self._description = description or ""
self._default = None
self._choices = choices

if self._is_list and self._flag:
raise CleoLogicError("A flag option cannot be a list as well")

if self._choices and self._flag:
raise CleoLogicError("A flag option cannot have choices")

if self._choices and not self._requires_value:
raise CleoLogicError("An option with choices requires a value")

self.set_default(default)

@property
Expand All @@ -65,6 +73,10 @@ def description(self) -> str:
def default(self) -> Any | None:
return self._default

@property
def choices(self) -> list[str] | None:
return self._choices

def is_flag(self) -> bool:
return self._flag

Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/application_choice_exception.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

Invalid value for the "foo" argument: "wrong_choice" (choose from "choice1", "choice2")
29 changes: 29 additions & 0 deletions tests/fixtures/choice_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import ClassVar

from cleo.commands.command import Command
from cleo.helpers import argument
from cleo.helpers import option


if TYPE_CHECKING:
from cleo.io.inputs.argument import Argument
from cleo.io.inputs.option import Option


class ChoiceCommand(Command):
name = "choice"
options: ClassVar[list[Option]] = [
option("baz", flag=False, description="Baz", choices=["choice1", "choice2"]),
]
arguments: ClassVar[list[Argument]] = [
argument("foo", description="Foo", choices=["choice1", "choice2"]),
]
help = "help"
description = "description"

def handle(self) -> int:
self.line("handle called")
return 0
12 changes: 12 additions & 0 deletions tests/io/inputs/test_argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,15 @@ def test_list_arguments_do_not_support_non_list_default_values() -> None:
description="Foo description",
default="bar",
)


def test_argument_with_choices() -> None:
argument = Argument("foo", choices=["choice1", "choice2"])

assert argument.name == "foo"
assert argument.choices == ["choice1", "choice2"]


def test_argument_default_not_in_choices() -> None:
with pytest.raises(CleoLogicError, match="A default value must be in choices"):
Argument("foo", default="arg0", choices=["arg1", "arg2"])
11 changes: 11 additions & 0 deletions tests/io/inputs/test_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def test_create() -> None:
assert not opt.requires_value()
assert not opt.is_list()
assert not opt.default
assert not opt.choices


def test_dashed_name() -> None:
Expand All @@ -40,6 +41,16 @@ def test_fail_if_wrong_default_value_for_list_option() -> None:
Option("option", flag=False, is_list=True, default="default")


def test_fail_if_choices_provided_for_flag() -> None:
with pytest.raises(CleoLogicError):
Option("option", flag=True, choices=["ch1", "ch2"])


def test_fail_if_choices_without_required_values() -> None:
with pytest.raises(CleoLogicError):
Option("option", flag=False, requires_value=False, choices=["ch1", "ch2"])


def test_shortcut() -> None:
opt = Option("option", "o")

Expand Down
15 changes: 15 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from cleo.io.io import IO
from cleo.io.outputs.stream_output import StreamOutput
from cleo.testers.application_tester import ApplicationTester
from tests.fixtures.choice_command import ChoiceCommand
from tests.fixtures.foo1_command import Foo1Command
from tests.fixtures.foo2_command import Foo2Command
from tests.fixtures.foo3_command import Foo3Command
Expand Down Expand Up @@ -370,6 +371,20 @@ def test_run_namespaced_with_input() -> None:
assert tester.io.fetch_output() == "Hello world!\n"


def test_run_with_choices() -> None:
app = Application()
command = ChoiceCommand()
app.add(command)

tester = ApplicationTester(app)
status_code = tester.execute("choice wrong_choice")

assert status_code != 0
assert tester.io.fetch_error() == FIXTURES_PATH.joinpath(
"application_choice_exception.txt"
).read_text(encoding="utf-8")


@pytest.mark.parametrize("cmd", (Foo3Command(), FooSubNamespaced3Command()))
def test_run_with_input_and_non_interactive(cmd: Command) -> None:
app = Application()
Expand Down
8 changes: 8 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,11 @@ def test_option() -> None:
assert opt.requires_value()
assert not opt.is_list()
assert opt.default == "bar"

opt = option("foo", "f", "Foo", flag=False, choices=["bar1", "bar2"])

assert opt.description == "Foo"
assert opt.accepts_value()
assert opt.requires_value()
assert not opt.is_list()
assert opt.choices == ["bar1", "bar2"]
Loading