DEV Community

Cover image for Typer: Observations, Dos and Don'ts of the Python Command Line Application Builder
Stephen Odogwu
Stephen Odogwu

Posted on

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
 Got unexpected extra argument (steve)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
python3 script.py call_name steve 23
Enter fullscreen mode Exit fullscreen mode

Error here:

Invalid value for '[AGE]': 'steve' is not a valid integer.
Enter fullscreen mode Exit fullscreen mode

Without function in command:

python3 script.py steve
Enter fullscreen mode Exit fullscreen mode

No errors observed.

Hi steve
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
python3 script.py callname steve
Enter fullscreen mode Exit fullscreen mode

Error here:

 Got unexpected extra argument (steve) 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode

Notice:

app.add_typer(sub_app, name="cli")
Enter fullscreen mode Exit fullscreen mode

Commands will be accessible under cli which is the value of name.

So that:

python script.py cli callname steve 30
Enter fullscreen mode Exit fullscreen mode

Will output:

Hi steve, are you 30?
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Run:

python script.py callname steve 22
Enter fullscreen mode Exit fullscreen mode

Output is:

Hi steve, are you 22
Enter fullscreen mode Exit fullscreen mode

Run:

python script.py checkdetails maka 36
Enter fullscreen mode Exit fullscreen mode

Output is:

Hi maka, are you 36
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Run this command:

python script.py create_name Steve
Enter fullscreen mode Exit fullscreen mode

Throws an error:

No such command create_name
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Now when we run the command:

python script.py create_name Steve
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

If we run:

python index.py greet_older --name kelly --older
Enter fullscreen mode Exit fullscreen mode

The above will trigger the if block. Without the --older flag, it will trigger the else block.

Hello Mr kelly
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode
python index.py greet_older --name kelly
Enter fullscreen mode Exit fullscreen mode

Will output:

Hello Mr kelly
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This will output:

Hello Kelly
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Run this:

python somehelp.py --help
Enter fullscreen mode Exit fullscreen mode

The above outputs the name argument with the value in he help parameter(The name to call). Then it shows us the --help flag under options.

Image description

However when we run this command:

python somehelp.py steve --help
Enter fullscreen mode Exit fullscreen mode

It doesn't print Hi steve instead it outputs what it did with our first command.

Image description

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)