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 support for optional values of CLI Options #1063

Open
wants to merge 8 commits into
base: master
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
65 changes: 65 additions & 0 deletions docs/tutorial/options/optional_value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Optional value for CLI Options

As in Click, providing a value to a *CLI option* can be made optional, in which case a default value will be used instead.

To make a *CLI option*'s value optional, you can annotate it as a *Union* of types *bool* and the parameter type.

/// info

You can create a type <a href="https://docs.python.org/3/library/typing.html#typing.Union" class="external-link" target="_blank">Union</a> by importing *Union* from the typing module.

For example `Union[bool, str]` represents a type that is either a boolean or a string.

You can also use the equivalent notation `bool | str`

///

Let's add a *CLI option* `--tone` with optional value:

{* docs_src/options/optional_value/tutorial001_an.py hl[5] *}

Now, there are three possible configurations:

* `--greeting` is not used, the parameter will receive a value of `False`.
```
python main.py
```

* `--greeting` is supplied with a value, the parameter will receive the string representation of that value.
```
python main.py --greeting <value>
```

* `--greeting` is used with no value, the parameter will receive the default `formal` value.
```
python main.py --greeting
```


And test it:

<div class="termy">

```console
$ python main.py Camila Gutiérrez

// We didn't pass the greeting CLI option, we get no greeting


// Now update it to pass the --greeting CLI option with default value
$ python main.py Camila Gutiérrez --greeting

Hello Camila Gutiérrez

// The above is equivalent to passing the --greeting CLI option with value `formal`
$ python main.py Camila Gutiérrez --greeting formal

Hello Camila Gutiérrez

// But you can select another value
$ python main.py Camila Gutiérrez --greeting casual

Hi Camila !
```

</div>
19 changes: 19 additions & 0 deletions docs_src/options/optional_value/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import typer


def main(name: str, lastname: str, greeting: bool | str = "formal"):
if not greeting:
return

if greeting == "formal":
print(f"Hello {name} {lastname}")

elif greeting == "casual":
print(f"Hi {name} !")

else:
raise ValueError(f"Invalid greeting '{greeting}'")


if __name__ == "__main__":
typer.run(main)
22 changes: 22 additions & 0 deletions docs_src/options/optional_value/tutorial001_an.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import typer
from typing_extensions import Annotated


def main(
name: str, lastname: str, greeting: Annotated[bool | str, typer.Option()] = "formal"
):
if not greeting:
return

if greeting == "formal":
print(f"Hello {name} {lastname}")

elif greeting == "casual":
print(f"Hi {name} !")

else:
raise ValueError(f"Invalid greeting '{greeting}'")


if __name__ == "__main__":
typer.run(main)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ nav:
- tutorial/options/index.md
- tutorial/options/help.md
- tutorial/options/required.md
- tutorial/options/optional_value.md
- tutorial/options/prompt.md
- tutorial/options/password.md
- tutorial/options/name.md
Expand Down
101 changes: 100 additions & 1 deletion tests/test_type_conversion.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from enum import Enum
from pathlib import Path
from typing import Any, List, Optional, Tuple
from typing import Any, List, Optional, Tuple, Union

import click
import pytest
import typer
from typer.testing import CliRunner
from typing_extensions import Annotated

from .utils import needs_py310

Expand Down Expand Up @@ -169,3 +170,101 @@ def custom_click_type(

result = runner.invoke(app, ["0x56"])
assert result.exit_code == 0


class TestOptionAcceptsOptionalValue:
def test_enum(self):
app = typer.Typer()

class OptEnum(str, Enum):
val1 = "val1"
val2 = "val2"

@app.command()
def cmd(opt: Annotated[Union[bool, OptEnum], typer.Option()] = OptEnum.val1):
if opt is False:
print("False")

else:
print(opt.value)

result = runner.invoke(app)
assert result.exit_code == 0, result.output
assert "False" in result.output

result = runner.invoke(app, ["--opt"])
assert result.exit_code == 0, result.output
assert "val1" in result.output

result = runner.invoke(app, ["--opt", "val1"])
assert result.exit_code == 0, result.output
assert "val1" in result.output

result = runner.invoke(app, ["--opt", "val2"])
assert result.exit_code == 0, result.output
assert "val2" in result.output

result = runner.invoke(app, ["--opt", "val3"])
assert result.exit_code != 0
assert "Invalid value for '--opt': 'val3' is not one of" in result.output

result = runner.invoke(app, ["--opt", "0"])
assert result.exit_code == 0, result.output
assert "False" in result.output

result = runner.invoke(app, ["--opt", "1"])
assert result.exit_code == 0, result.output
assert "val1" in result.output

def test_int(self):
app = typer.Typer()

@app.command()
def cmd(opt: Annotated[Union[bool, int], typer.Option()] = 1):
print(opt)

result = runner.invoke(app)
assert result.exit_code == 0, result.output
assert "False" in result.output

result = runner.invoke(app, ["--opt"])
assert result.exit_code == 0, result.output
assert "1" in result.output

result = runner.invoke(app, ["--opt", "2"])
assert result.exit_code == 0, result.output
assert "2" in result.output

result = runner.invoke(app, ["--opt", "test"])
assert result.exit_code != 0
assert (
"Invalid value for '--opt': 'test' is not a valid integer" in result.output
)

result = runner.invoke(app, ["--opt", "true"])
assert result.exit_code == 0, result.output
assert "1" in result.output

result = runner.invoke(app, ["--opt", "off"])
assert result.exit_code == 0, result.output
assert "False" in result.output

def test_path(self):
app = typer.Typer()

@app.command()
def cmd(opt: Annotated[Union[bool, Path], typer.Option()] = Path(".")):
if isinstance(opt, Path):
print((opt / "file.py").as_posix())

result = runner.invoke(app, ["--opt"])
assert result.exit_code == 0, result.output
assert "file.py" in result.output

result = runner.invoke(app, ["--opt", "/test/path/file.py"])
assert result.exit_code == 0, result.output
assert "/test/path/file.py" in result.output

result = runner.invoke(app, ["--opt", "False"])
assert result.exit_code == 0, result.output
assert "file.py" not in result.output
2 changes: 2 additions & 0 deletions typer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ def __init__(
prompt_required: bool = True,
hide_input: bool = False,
is_flag: Optional[bool] = None,
flag_value: Optional[Any] = None,
multiple: bool = False,
count: bool = False,
allow_from_autoenv: bool = True,
Expand All @@ -446,6 +447,7 @@ def __init__(
confirmation_prompt=confirmation_prompt,
hide_input=hide_input,
is_flag=is_flag,
flag_value=flag_value,
multiple=multiple,
count=count,
allow_from_autoenv=allow_from_autoenv,
Expand Down
Loading
Loading