click.Choice and type narrowing
2023/10/25
I am a heavy click
user, but the lack of typesafety from the click decorators to the types in the function signature often bites me when making changes.
For example, this does not type error:
import click
@click.command()
@click.option("--flag", type=int, default=None)
def export(flag: int) -> None:
print(flag + 5)
The type=int
is just there for click
to parse the user input. If the user didn’t provide anything, it ends up throwing an error at runtime since it defaults to None
print(flag + 5)
~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
However, mypy won’t warn us about that:
There’s no real way to fix the type in this case - this is an example of how your types for untyped code will just be casted, mypy assumes you’re correct when you specify those.
I very often use click.Choice
to describe an output format for a command and then match
on the format. A very basic example:
import click
@click.command()
@click.option("-o", "--ouptut", type=click.Choice(["text", "json"]), default="text")
def main(output: str) -> None:
data = "data"
match output:
case "text":
print(data)
case "json":
print({"data": data})
case _:
raise ValueError(f"Unknown output format: {output}")
if __name__ == "__main__":
main()
However, if I add another format to the Choice
, it would just skip it, since it does not match text
or json
.
I did the obvious thing and added default case and raise an error, but that is a runtime error – I’ve sometimes forgot to implement one of the cases for click.Choice
Because we typed output
as a str
, there are quite literally infinite possibilities for it. Instead, we can constrain it a bit more by using a typing.Literal
:
+import typing
+
import click
+OutputFormat = typing.Literal["text", "json"]
+
@click.command()
@click.option("-o", "--ouptut", type=click.Choice(["text", "json"]), default="text")
-def main(output: str) -> None:
+def main(output: OutputFormat) -> None:
data = "data"
match output:
You can get the arguments out of a Literal
by using typing.get_args
, so we could remove the duplication in the @click.option
:
@click.option("-o", "--ouptut", type=click.Choice(typing.get_args(OutputFormat)), default="text")
If you wanted to remove the hardcoded "text"
from the default, you could also use typing.get_args(OutputFormat)[0]
to index into the first item in the Literal to pull out "text"
.
However, if we add another format (say table
), it’s still just going to raise the ValueError
In order to ‘narrow’ the type so that mypy can infer that we’re missing a typing.Literal
case, we can use typing.assert_never
instead, to tell mypy
that the default case should never be called:
import sys
from typing import Literal, get_args
import click
# Never is a new feature, so try to import it from the typing_extensions
# package if it's not available in the current version of Python.
if sys.version_info < (3, 11):
from typing_extensions import assert_never
else:
from typing import assert_never
OutputFormat = Literal["text", "json"]
@click.command()
@click.option(
"-o", "--ouptut", type=click.Choice(get_args(OutputFormat)), default="text"
)
def main(output: OutputFormat) -> None:
data = "data"
match output:
case "text":
print(data)
case "json":
print({"data": data})
case _:
assert_never(output)
if __name__ == "__main__":
main()
assert_never
is itself a very simple function that looks like this:
def assert_never(arg: Never) -> Never:
raise AssertionError(f"Unhandled value: {arg!r}")
the Never
type tells mypy
that this is a ‘bottom type’, it has no members and should never be called.
Now, if we update the OutputFormat
Literal
and add "table"
, it gets added to the click.Choice
automatically because of get_args
, and we get a warning from mypy
!
-OutputFormat = Literal["text", "json"]
+OutputFormat = Literal["text", "json", "table"]
example.py:30: error: Argument 1 to "assert_never" has incompatible type "Literal['table']"; expected "NoReturn" [arg-type]
Found 1 error in 1 file (checked 1 source file)