Happy New Year! To get the blog going after the holidays, I am going to write a series of posts (with each post being as short as possible – I’m aiming for 5-7 minutes read time) about building a command line application. The application with use Typer, which is a Python package for creating command line applications. It is written by the maintainer of FastAPI so as you might guess, it makes judicious use of modern Python features such as type hints. The data for the application will be stored in a local SQLite database. The Python standard library includes a module for accessing a SQLite database but as the application grows, it will become tedious constructing SQL statements based on the available options. Instead, we will use SQLModel, a proxy/wrapper around SQLAlchemy as an ORM. SQLModel is also maintained by the same person who created FastAPI and Typer. Long story short, you’re going to see a lot of Python type hints. And finally, command line applications are text-only. But that doesn’t mean they have to be boring. The rich package lets us use color and formatting as well as UI layouts like tables to make the output of the application easier to read.
To install Typer, go to the command prompt. In Visual Studio Code, you can press Ctrl-` to open the Terminal pane. At the prompt install Typer with pip
:
$ pip install typer
In a new Python file, main.py, write a function that displays some text in the terminal:
def say_hello():
print("Hey there!")
To call this function from a Typer app, we first need to create one. Import the typer
module, and then create a new instance of a Typer
application:
import typer
app = typer.Typer()
Now the say_hello
function need to be decorated with the command
decorator.
@app.command()
def say_hello():
print("Hey there!")
A command in a Typer application is passed to the application at the command line. The name of the command is by default, the same as the decorated function, replacing underscores with dashes. In this case, the name of the command would be say-hello
.
Add one more function, decorated with the command
decorator, to display a farewell message:
@app.command()
def say_goodbye():
print("So long!")
The last step is to define the entry point. Simply call app
to get things up and running:
if __name__ == "__main__":
app()
Now at the command line, invoke the main.py script, and pass it the name of the say-hello
command:
$ python main.py say-hello
Hey there!
As you might guess, the say-goodbye
command will invoke the say_goodbye function:
$ python main.py say-hello
So long!
In addition, you get some built-in help. Invoke main.py with the --help
option:
$ python main.py --help
Usage: main.py [OPTIONS] COMMAND [ARGS]...
╭─Options─────────────────────────────────────────────────────────────╮
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to │
│ copy it or customize the installation. │
│ --help Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────╯
╭─Commands────────────────────────────────────────────────────────────╮
│ say-hello │
│ say-goodbye │
╰─────────────────────────────────────────────────────────────────────╯
Typer automatically provides a list of the commands we registered with the application. It also show us some default options we can invoke but those are beyond the scope of this post.
Arguments
The point so far is that you get a lot for free from Typer’s built in conventions. There are more conventions that can configure arguments and options to the commands. Modify the say_hello
function to greet a specific person:
@app.command()
def say_hello(name: str):
print(f"Hey there {name}")
As mentioned earlier, Typer (along with SQLModel) makes use of type hints. The other thing to notice is what you get when attempting to invoke the say-hello
command:
$ python main.py say-hello
Usage: main.py say-hello [OPTIONS] NAME
Try 'main.py say-hello --help' for help.
╭─Error────────────────────────────────────────────────────────────────╮
│ Missing argument 'NAME'. │
╰──────────────────────────────────────────────────────────────────────╯
Typer assumes that the name parameter to the say_hello
function will be an argument to the say-hello
command. It also suggests running the command with the --help
option for more details.
$ python main.py say-hello --help
Usage: main.py say-hello [OPTIONS] NAME
╭─Arguments───────────────────────────────────────────────────────────╮
│ * name TEXT [default: None] [required] │
╰─────────────────────────────────────────────────────────────────────╯
╭─Options─────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────╯
The --help
option displays the arguments and options for the command. Here we can see that name is a text argument and is it required. There are no options other than --help
which is built in. We’ll add options in a minute. But let’s go ahead and invoke the say-hello
command with the argument:
$ python main.py say-hello Typer
Hey there Typer!
Options
Now let’s add an option that adds exclamation points to the greeting. Options are also passed to the function decorated with the command
decorator.
@app.command()
def say_hello(name: str, enthusiasm: int = 1):
print(f"Hey there {name}{'!' * enthusiasm}")
In this example, the enthusiasm
parameter to the say_hello
function becomes the --enthusiasm
option to the say-hello
command. Notice the enthusiasm
parameter has a default value of 1. Thus, it is optional and there will be only one exclamation point if the option is omitted. Try it out:
$ python main.py say-hello Typer
Hey there Typer!
Now include the --enthusiasm
option and give it a value of 10:
$ python main.py say-hello Typer --enthusiasm 10
Hey there Typer!!!!!!!!!!
And take another look at the help for the say-hello
command:
$ python main.py say-hello --help
Usage: main.py say-hello [OPTIONS] NAME
╭─Arguments───────────────────────────────────────────────────────────╮
│ * name TEXT [default: None] [required] │
╰─────────────────────────────────────────────────────────────────────╯
╭─Options─────────────────────────────────────────────────────────────╮
│ --enthusiasm INTEGER [default: 1] │
│ --help Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────╯
The help for the --enthusiasm
option shows that it is an integer and that 1 is the default value.
Styling with Rich
There is one problem with the --enthusiasm
option, negative numbers are not handled correctly. It won’t make the application crash, but the user won’t know that they shouldn’t use negative numbers. Let’s fix that.
Add a conditional to check if the --enthusiasm
option value is less than 0 and print an error if so.
@app.command()
def say_hello(name: str, enthusiasm: int = 1):
if enthusiasm < 0:
print("Enthusiasm cannot be negative!")
else:
print(f"Hey there {name}{'!' * enthusiasm}")
Try it out to see the results:
$ python main.py say-hello Typer --enthusiasm -1
Enthusiasm cannot be negative
Since this is an error, we should make it obvious to the user. With the Rich package, we can format the error to make it stand out. Let’s style the error in bold red text. Install the Rich package with pip
to get started
$ pip install rich
Rich has a special Console
type that understands how to format text in command line applications. You’ll need to import it and create a new instance of the type:
from rich.console import Console
console = Console()
By calling the print
method on the console
, you can markup the text with special tags to make it bold and red.
console.print("[bold red]Enthusiasm cannot be negative[/bold red]")
Enthusiasm cannot be negative
However, this will break the DRY principle. It’s likely that we will want to format errors other places in the application. Therefore, using the Style
type, we can configure a style one time and then apply in multiple places for reusability and maintenance of the code.
Import the Style
type from the rich.style
module.
from rich.style import Style
Also, create a dataclass that will group all of the styles for the application in a single place. Define the error style to be bold red.
from dataclasses import dataclass
class ApplicationStyles:
error: Style = Style(color="red", bold=True)
Create an instance of the ApplicationStyles
class.
app_styles = ApplicationStyles()
Remove the markup from the error message and set the style
keyword argument of the console.print
method to the error style.
console.print("Enthusiasm cannot be negative", style=app_styles.error)
The end user won’t see anything different. But you’ve just made the code easier to read and maintain by not littering markup in the print functions. As we continue to build the app, we’ll add more style to the ApplicationStyles
dataclass. So if it seems like overfill right now, stay tuned to see how it will make things easier as the complexity of the applications increases in future posts.
Summary
In this post you learned how to create a simple command line application using Typer. You learned how to create command functions and register them with the application. You also learned how to use parameters and type hints to the command functions to create arguments and options to the commands. And you got a taste of using Rich styles to format text in the application.