Typer: Observations, Dos and Don'ts of the Python Command Line Application Builder
Typer is a library for building command line interface(CLI) applications based on Python type hints. We have seen CLI applications like Curl, Git and the likes, Typer comes in handy for projects like these. Currently building an application with it and I must say I like the whole concept behind it.
This article is not one to introduce us to Typer. It is for those already familiar with it. In this article, we will be making observations, looking at certain things that can cause errors in our code and how to avoid them. I won't be talking about things like installation as I believe we should be familiar with that.
Now going to look at some occurences, behaviors and implementations of Typer which could lead to errors if not followed, and how to avoid these errors.
Note: For our commands python3 and python were used interchangeably.You can use whichever applies to you.
One Function Module and Extra Argument Error
If we have a function as the only function in a module, Typer assumes that any argument passed on the command line must map to a defined argument for the function. Create a file `script.py and add the following code.
import typer
app = typer.Typer()
@app.command(name="call_name")
def call_name(
name: str
):
print(f"Hi {name}")
if __name__ == "__main__":
app()
This function is a command, as can be seen by the @app.command()
passed over it. The command name is inferred from that, and its arguments are treated as top level options. Including the function name as part of the command causes an extra argument error.
python3 script.py call_name steve
Got unexpected extra argument (steve)
If the function has more than one argument, it pushes the first argument to the position of the second, since it assumes the function being passed is the first argument and that the first argument is the second. In this scenario if our arguments are of different types like int
and str
, it shows a value error.
from typing_extensions import Annotated
import typer
app = typer.Typer()
@app.command(name="call_name")
def call_name(
name: str,
age: Annotated[int, typer.Argument()] = 25
):
print(f"Hi {name}")
if __name__ == "__main__":
app()
python3 script.py call_name steve 23
Error here:
Invalid value for '[AGE]': 'steve' is not a valid integer.
Without function in command:
python3 script.py steve
No errors observed.
Hi steve
typer.run()
Too
The above doesn't just happen in a single function module where the function is registered with @app.command()
. It also occurs when we register the function with typer.run()
.
import typer
def callname(
name: str
):
print(f"Hi {name}")
if __name__ == "__main__":
typer.run(callname)
python3 script.py callname steve
Error here:
Got unexpected extra argument (steve)
This is because Typer sees this function as the sole command. Adding our function name in the CLI makes Typer think we are adding some unexpected argument.
Subcommands to The Rescue
However we can achieve something similar with the use of subcommands. The basic idea is adding a typer.Typer()
app in another typer.Typer()
app.
Typer application has a method known as add_typer
which we can use to achieve this. It takes the second Typer instance created from typer.Typer()
and the name of the group under which the commands in the typer instance will be accessible in the parent CLI application.
import typer
app = typer.Typer() #parent
sub_app = typer.Typer() #child
from typing_extensions import Annotated
import typer
app = typer.Typer() #parent
sub_app = typer.Typer() #child
@sub_app.command()
def callname(
name: str,
age: Annotated[int, typer.Argument()] = 25
):
print(f"Hi {name}, are you {age}?")
app.add_typer(sub_app, name="cli")
if __name__ == "__main__":
app()
Notice:
app.add_typer(sub_app, name="cli")
Commands will be accessible under cli
which is the value of name
.
So that:
python script.py cli callname steve 30
Will output:
Hi steve, are you 30?
Multiple Functions
If our module has more than one function, Typer uses the functions' names to route arguments properly. Using the command names to distinguish them in this scenario.
from typing_extensions import Annotated
import typer
app = typer.Typer()
@app.command()
def callname(
name: str= typer.Argument(help="The name to call"),
age:int= typer.Argument(help="Enter age here:")
):
print(f"Hi {name}, are you {age}")
@app.command()
def checkdetails(
name: str= typer.Argument(help="The name to call"),
age:int= typer.Argument(help="Enter age here:")
):
print(f"Hi {name}, are you {age}")
if __name__ == "__main__":
app()
Run:
python script.py callname steve 22
Output is:
Hi steve, are you 22
Run:
python script.py checkdetails maka 36
Output is:
Hi maka, are you 36
Don't Use Snake Case Functions on the Command Line Unless....
Suppose we have this code in a script.py
file.
import typer
app=typer.Typer()
@app.command()
def create_name(username:str):
print(username)
@app.command()
def create_age(age:int):
print(age)
if __name__=="__main__":
app()
Run this command:
python script.py create_name Steve
Throws an error:
No such command create_name
This is because Typer automatically converts function names written in snake case to kebab case(i.e from underscores to dashes).
However if we want the CLI command to accept a snake case function name, we can explicitly specify it with the name
parameter in command.
# Note name argument in decorator
import typer
app=typer.Typer()
@app.command(name="create_name")
def create_name(username:str):
print(username)
@app.command(name="create_age")
def create_age(age:int):
print(age)
if __name__=="__main__":
app()
Now when we run the command:
python script.py create_name Steve
It outputs Steve.
Boolean Flags
Suppose we have an index.py
file that has the function below.
@app.command(name="greet_older")
def greet_older(name: str="", older:bool=False):
if older:
print(f"Hello Mr {name}")
else:
print(f" Hello {name}")
If we run:
python index.py greet_older --name kelly --older
The above will trigger the if
block. Without the --older
flag, it will trigger the else
block.
Hello Mr kelly
But What If....??
Suppose we had set the default value of older
to True
and not used --older
on the command line like below:
@app.command(name="greet_older")
def greet_older(name:str="", older:bool=True):
if older:
print(f"Hello Mr {name}")
else:
print(f"Hello {name}")
python index.py greet_older --name kelly
Will output:
Hello Mr kelly
Why did this happen, even though we didn't use the --older
flag; this is because, we hve changed value for what an unsued CLI flag is. From False
to True
.
In this scenario, the way we can handle the condition where we don't want to deal with the older
argument/option is to add the flag --no-older
.
So we can run:
python index.py greet_older --name Kelly --no-older
This will output:
Hello Kelly
This is because, if the default is True
, Typer provides a --no-[flag] to set it to False
. If the default is False
, Typer provides a --[flag] to set it to True
.
CLI Arguments With Help
We know that we can add help comments to our application by using a function's docstring. However we can do the same for specific arguments by adding a help
parameter to typer.Argument()
.
Create a file somehelp.py
and add the following code:
from typing_extensions import Annotated
import typer
def greet(
name: Annotated[str, typer.Argument(help="The name to call")] = "spencer",
):
print(f"Hi {name}")
if __name__ == "__main__":
typer.run(greet)
Run this:
python somehelp.py --help
The above outputs the name
argument with the value in the help
parameter(The name to call). Then it shows us the --help
flag under options.
However when we run this command:
python somehelp.py steve --help
It doesn't print Hi steve
instead it outputs what it did with our first command.
The reason for this is that when we pass --help
as an argument, Typer processes it and exits after displaying the help message. The function greet
is not executed when --help
is used. The --help
flag takes priority.
But if we run:
python somehelp.py steve
It prints Hi steve
.
Conclusion
Even though I wrote on just these points, one thing to note however, is that this is not an exhaustive list of all Typer's behaviors. These are just some observations I made while learning to use the tool. I encourage further investigations. Any inconsistencies on my part should be pointed out. Thanks for reading.