Skip to content

Commit a322f73

Browse files
committed
Make _Option public as PromptOption and add ChoiceOption counterpart.
1 parent fab726d commit a322f73

11 files changed

Lines changed: 230 additions & 9 deletions

consolekit/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
# this package
5151
from consolekit import _readline, commands, input, terminal_colours, tracebacks, utils # noqa: F401
5252
from consolekit.commands import SuggestionGroup
53-
from consolekit.options import _Option
53+
from consolekit.options import PromptOption
5454

5555
# pylint: enable=redefined-builtin
5656

@@ -132,7 +132,7 @@ def option(
132132
:param \*\*attrs: Additional keyword arguments passed to :func:`click.command`.
133133
"""
134134

135-
attrs.setdefault("cls", _Option)
135+
attrs.setdefault("cls", PromptOption)
136136
return cast(Callable[[_C], _C], click.option(*param_decls, **attrs))
137137

138138

consolekit/__init__.pyi

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ from consolekit import terminal_colours as terminal_colours # noqa: F401
1313
from consolekit import tracebacks as tracebacks # noqa: F401
1414
from consolekit import utils as utils # noqa: F401
1515
from consolekit.commands import SuggestionGroup as SuggestionGroup # noqa: F401
16-
from consolekit.options import _Option # noqa: F401
1716

1817
__author__: str
1918
__copyright__: str

consolekit/options.py

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060

6161
# stdlib
6262
import inspect
63-
from typing import Any, Callable, Iterable, List, Optional, Tuple, TypeVar, cast
63+
from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union, cast
6464

6565
# 3rd party
6666
import click
@@ -77,9 +77,12 @@
7777
"colour_option",
7878
"force_option",
7979
"no_pager_option",
80+
"PromptOption",
81+
"ChoiceOption",
8082
"MultiValueOption",
8183
"flag_option",
8284
"auto_default_option",
85+
"VerboseVersionCountType",
8386
"auto_default_argument",
8487
"DescribedArgument",
8588
"_A",
@@ -457,13 +460,20 @@ def get_default(self, ctx): # noqa: MAN001,MAN002,D102
457460
return ret
458461

459462

460-
class _Option(click.Option):
463+
class PromptOption(click.Option):
464+
"""
465+
:class:`click.Option` subclass that prompts for a value if none is provided on the command line.
466+
467+
Supports boolean, string or numeric values.
461468
462-
def prompt_for_value(self, ctx: click.Context): # noqa: MAN002,PRM002
469+
.. versionadded:: 1.11.0
470+
"""
471+
472+
def prompt_for_value(self, ctx: click.Context): # noqa: MAN002
463473
"""
464-
This is an alternative flow that can be activated in the full value processing if a value does not exist.
474+
Prompt the user until a valid value exists and then returns the processed value as result.
465475
466-
It will prompt the user until a valid value exists and then returns the processed value as result.
476+
:param ctx:
467477
"""
468478

469479
# Calculate the default before prompting anything to be stable.
@@ -489,6 +499,72 @@ def prompt_for_value(self, ctx: click.Context): # noqa: MAN002,PRM002
489499
)
490500

491501

502+
class ChoiceOption(click.Option): # noqa: PRM002
503+
"""
504+
:class:`click.Option` subclass for a string choice.
505+
506+
If a value is not provided a prompt (where the user chooses the corresponding number) is shown.
507+
508+
:param param_decls: The parameter declarations for this option.
509+
:param type: The type that should be used.
510+
:param show_default: Show the default value for this option in its help text.
511+
Values are not shown by default, unless :attr:`Context.show_default` is :py:obj:`True`.
512+
If this value is a string, it shows that string in parentheses instead of the actual value.
513+
This is particularly useful for dynamic options.
514+
For single option boolean flags, the default remains hidden if its value is :py:obj:`False`.
515+
:param prompt: The prompt text. Defaults to the option name (capitalised and with spaces) if unset.
516+
:param help: The help text.
517+
:param hidden: Hide this option from help outputs.
518+
:param show_choices:
519+
:param deprecated: If :py:obj:`True` or a non-empty string, issues a message indicating that the argument is deprecated
520+
and highlights its deprecation in --help.
521+
The message can be customized by using a string as the value.
522+
A deprecated parameter cannot be required, a ValueError will be raised otherwise.
523+
524+
.. versionadded:: 1.11.0
525+
"""
526+
527+
# TODO: type: click.Choice[str]
528+
type: click.Choice
529+
prompt: str
530+
531+
def __init__(
532+
self,
533+
param_decls: Sequence[str],
534+
# TODO: type: click.Choice[str], # noqa: A002 # pylint: disable=redefined-builtin
535+
type: click.Choice, # noqa: A002 # pylint: disable=redefined-builtin
536+
show_default: Union[bool, str, None] = None,
537+
prompt: str = '',
538+
help: Optional[str] = None, # noqa: A002 # pylint: disable=redefined-builtin
539+
hidden: bool = False,
540+
show_choices: bool = True,
541+
# deprecated: Union[bool, str] = False,
542+
**kwargs,
543+
) -> None:
544+
545+
super().__init__(
546+
param_decls=param_decls,
547+
show_default=show_default,
548+
prompt=prompt or True,
549+
help=help,
550+
hidden=hidden,
551+
show_choices=show_choices,
552+
# deprecated=deprecated,
553+
type=type,
554+
default=None,
555+
)
556+
557+
def prompt_for_value(self, ctx: click.Context) -> str:
558+
"""
559+
Show the prompt if a value was not provided.
560+
561+
:param ctx:
562+
"""
563+
564+
choices = list(self.type.choices)
565+
return choices[consolekit.input.choice(choices, text=self.prompt, start_index=1)]
566+
567+
492568
class DescribedArgument(click.Argument): # noqa: PRM002
493569
r"""
494570
:class:`click.Argument` with an additional keyword argument and attribute giving a short description.

tests/test_options.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44

55
# 3rd party
66
import click
7+
import pytest
8+
from coincidence.regressions import AdvancedFileRegressionFixture
79
from pytest_regressions.file_regression import FileRegressionFixture
810

911
# this package
1012
from consolekit import click_command
1113
from consolekit.options import (
14+
ChoiceOption,
1215
DescribedArgument,
1316
MultiValueOption,
17+
PromptOption,
1418
auto_default_argument,
1519
auto_default_option,
1620
colour_option,
@@ -21,7 +25,7 @@
2125
version_option
2226
)
2327
from consolekit.terminal_colours import ColourTrilean
24-
from consolekit.testing import CliRunner, Result
28+
from consolekit.testing import CliRunner, Result, _click_major, _click_version, click_8_only, not_click_8
2529

2630

2731
def test_described_argument(
@@ -305,3 +309,107 @@ def main(greeting: str = "Hello") -> None:
305309
result = cli_runner.invoke(main, args=["Good Morning"])
306310
assert result.stdout.rstrip() == "Good Morning User!"
307311
assert result.exit_code == 0
312+
313+
314+
@pytest.mark.parametrize(
315+
"click_version",
316+
[pytest.param('7', marks=click_8_only), pytest.param('8', marks=not_click_8)],
317+
)
318+
def test_choice_option(
319+
advanced_file_regression: AdvancedFileRegressionFixture,
320+
click_version: str,
321+
cli_runner: CliRunner,
322+
):
323+
324+
@click.option(
325+
"-s",
326+
"--station",
327+
help="The station to play.",
328+
type=click.Choice(["Radio 1", "Radio 2", "Radio 3"], case_sensitive=False),
329+
cls=ChoiceOption,
330+
prompt="Select a station",
331+
)
332+
@click_command()
333+
def main(station: str) -> None:
334+
print(f"Tuning to station: {station}")
335+
336+
result = cli_runner.invoke(main, input="5\n0\n2\n")
337+
assert result.exit_code == 0
338+
advanced_file_regression.check(result.stdout.rstrip())
339+
340+
result = cli_runner.invoke(main, args=["--station", "Radio 1"])
341+
assert result.exit_code == 0
342+
assert result.stdout.rstrip() == "Tuning to station: Radio 1"
343+
344+
345+
not_click_8_or_above_82 = pytest.mark.skipif(
346+
_click_major != 8 or (_click_major == 8 and _click_version[1] >= 2), reason="Output differs on click 8"
347+
)
348+
not_click_8_or_below_82 = pytest.mark.skipif(
349+
_click_major != 8 or (_click_major == 8 and _click_version[1] < 2), reason="Output differs on click 8.2"
350+
)
351+
352+
353+
@pytest.mark.parametrize(
354+
"click_version",
355+
[
356+
pytest.param('7', marks=click_8_only),
357+
pytest.param('8', marks=not_click_8_or_above_82),
358+
pytest.param("8.2", marks=not_click_8_or_below_82),
359+
],
360+
)
361+
def test_prompt_option(
362+
advanced_file_regression: AdvancedFileRegressionFixture,
363+
click_version: str,
364+
cli_runner: CliRunner,
365+
):
366+
367+
@click.option(
368+
"-s",
369+
"--station",
370+
help="The station to play.",
371+
type=click.Choice(["Radio 1", "Radio 2", "Radio 3"], case_sensitive=False),
372+
cls=PromptOption,
373+
prompt="Select a station",
374+
)
375+
@click_command()
376+
def main(station: str) -> None:
377+
print(f"Tuning to station: {station}")
378+
379+
result = cli_runner.invoke(main, input="Radio 4\nRadio 2\n")
380+
assert result.exit_code == 0
381+
advanced_file_regression.check(result.stdout.rstrip())
382+
383+
result = cli_runner.invoke(main, args=["--station", "Radio 1"])
384+
assert result.exit_code == 0
385+
assert result.stdout.rstrip() == "Tuning to station: Radio 1"
386+
387+
388+
@pytest.mark.parametrize(
389+
"click_version",
390+
[pytest.param('7', marks=click_8_only), pytest.param('8', marks=not_click_8)],
391+
)
392+
def test_prompt_option_flag(
393+
advanced_file_regression: AdvancedFileRegressionFixture,
394+
click_version: str,
395+
cli_runner: CliRunner,
396+
):
397+
398+
@flag_option(
399+
"--colour/--no-colour",
400+
help="Show colour in output.",
401+
cls=PromptOption,
402+
default=None,
403+
prompt="Show colour:",
404+
)
405+
@click_command()
406+
def main(colour: bool) -> None:
407+
print(f"Show colour? {colour}")
408+
409+
result = cli_runner.invoke(main, input="Z\nY\n")
410+
assert result.exit_code == 0
411+
advanced_file_regression.check(result.stdout.rstrip())
412+
413+
result = cli_runner.invoke(main, args=["--no-colour"])
414+
assert result.exit_code == 0
415+
assert result.stdout.rstrip() == "Show colour? False"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[1] Radio 1
2+
[2] Radio 2
3+
[3] Radio 3
4+
Select a station: 5
5+
Error: 5 is not in the valid range of 1 to 3.
6+
Select a station: 0
7+
Error: 0 is not in the valid range of 1 to 3.
8+
Select a station: 2
9+
Tuning to station: Radio 2
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[1] Radio 1
2+
[2] Radio 2
3+
[3] Radio 3
4+
Select a station: 5
5+
Error: 5 is not in the range 1<=x<=3.
6+
Select a station: 0
7+
Error: 0 is not in the range 1<=x<=3.
8+
Select a station: 2
9+
Tuning to station: Radio 2
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Select a station (Radio 1, Radio 2, Radio 3): Radio 4
2+
Error: invalid choice: Radio 4. (choose from Radio 1, Radio 2, Radio 3)
3+
Select a station (Radio 1, Radio 2, Radio 3): Radio 2
4+
Tuning to station: Radio 2
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Select a station (Radio 1, Radio 2, Radio 3): Radio 4
2+
Error: 'Radio 4' is not one of 'Radio 1', 'Radio 2', 'Radio 3'.
3+
Select a station (Radio 1, Radio 2, Radio 3): Radio 2
4+
Tuning to station: Radio 2
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Select a station (Radio 1, Radio 2, Radio 3): Radio 4
2+
Error: 'Radio 4' is not one of 'radio 1', 'radio 2', 'radio 3'.
3+
Select a station (Radio 1, Radio 2, Radio 3): Radio 2
4+
Tuning to station: Radio 2
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Show colour: [y/N]: Z
2+
Error: invalid input
3+
Show colour: [y/N]: Y
4+
Show colour? True

0 commit comments

Comments
 (0)