Typer: Observations, Dos and Don'ts of the Python Command Line Application Builder

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.

helpedtoedit

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.

editedcrop

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.