Build Your First CLI Application With Typer
In this article, we will be building a CLI application with Typer and Python. we will build a to-do application with CRUD functionality.
Table of contents
- What is Typer?
- Project Setup
- Create a project folder and give it a name
- Navigate to the folder
- Create a virtual environment and activate it
- Install dependencies
- Create a README file for your project
- Create requirements.txt and add your project dependencies to it.
- Create .gitignore file to tell git which file or folder to ignore in a project
- To-do CLI Business Logic
- Summary
- Conclusion
- Connect with me
Howdy everyone, In this article, we will be building a CLI application with Typer and Python. we will build a to-do application with CRUD(Create Read Update Delete) functionality.
Before building our to-do CLI first let's discuss what is typer and its features.
What is Typer?
Typer is a python library for building CLI applications with python and type hints. it provides a lot of features and some of them are
- Simple to start
- Easy to use
- Easy to debug
- Scalable
- Less code
- Easy to follow docs
Let's build our to-do CLI
Project Setup
Create a project folder and give it a name
> mkdir todo_cli
Navigate to the folder
> cd todo_cli
Create a virtual environment and activate it
Virtual environment is an isolated environment that keeps your dependencies isolated so your global environment does not affect it.
We will be using venv
to create a virtual environment. venv
is a python module for creating lightweight "virtual environments".
> python -m venv env
> source env/bin/activate
Now, we have the virtual environment activated let's install the required dependencies
Install dependencies
This command will install the type
and additional dependencies like Colorama
, shellingham
and click
.
Typer uses Click
internally. it is a command-line creation toolkit.
- Colorama - to make sure your terminal colour work correctly.
- shellingham - to detect current shell
> pip install typer[all]
Create a README file for your project
This will help end-users to read the project description and instructions to use it.
> touch README.md
Create requirements.txt
and add your project dependencies to it.
> touch requirements.txt
After creating the requirements.txt file add the below content to it.
typer==0.3.2
colorama==0.4.4
shellingham==1.4.0
Create .gitignore
file to tell git which file or folder to ignore in a project
> touch .gitignore
After the .gitignore
file is created add the below content to it.
.pytest_cache
__pycache__
venv
env
*.pyc
Now our basic project structure is ready, we can move forward and create our todo
package which will contain our to-do CLI business logic.
To-do CLI Business Logic
For brevity we will use dots ... to denote existing code that remains unchanged
Our todo will have this data structure.
{
'Description': "Describe your todo",
'Priority': "Priority of your todo" ,
'Done': "Is completed or not"
}
Create a folder and name it todo
# todo_cli/todo
> mkdir todo_cli/todo
Create a file named __init__.py
inside todo
folder
Python has a straightforward packaging system. you have to create the __init__.py
file inside the folder and python will treat that folder as a package.
# todo_cli/todo/__inti__.py
> touch todo_cli/todo/__inti__.py
Now set up the todo
package by adding some codes to the __init__.py
file.
Here we are adding __app_name__
and __version__
for our todo
package.
also, we are adding an ERRORS
dictionary with error codes like SUCCESS, ID_ERROR, etc.
# todo/__init__.py
__app_name__="todo"
__version__="0.1.0"
# this will assign numbers from 0 to 6
(
SUCCESS,
DIR_ERROR,
FILE_ERROR,
DB_READ_ERROR,
DB_WRITE_ERROR,
JSON_ERROR,
ID_ERROR,
) = range(7)
# this will create errors dictionary with error code
ERRORS = {
DIR_ERROR: "config directory error",
FILE_ERROR: "config file error",
DB_READ_ERROR: "database read error",
DB_WRITE_ERROR: "database write error",
ID_ERROR: "to-do id error",
}
We will be going to use the ERRORS
dictionary throughout the project to provide consistent error codes and messages.
Want to read more about python dictionaries and structuring data, you can read it from here.
To work with CLI applications we will need to set up some configs and in the next section, we will create a config module for our to-do CLI.
Config Module
Create a file named config.py
inside todo
folder
# todo_cli/todo/config.py
> touch todo_cli/todo/config.py
To store our to-dos we will need a database, for that we will be going to use a JSON
file as a database to store our to-dos.
Also, we will need to create an application config file so that someone using it will get initial configs.
Open a config.py
file and add the below code to it.
import configparser
from pathlib import Path
import typer
from todo import (
__app_name__
)
CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__))
CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini"
Here we are importing configparser
, Path
from pathlib Package and typer
.
Configparser - to implement basic configuration language for python programs.
Path - to work with system paths in a cross-platform way.
typer - to work with typer helper functions and classes.
We have created CONFIG_DIR_PATH
to hold our application config directory path using __app_name__
that we have defined earlier in our project.
CONFIG_FILE_PATH
to hold the project config file path.
Setting up the config is a one-time task and for that, we will create an initializer method to set up the initial config.
...
from todo import (
__app_name__,
DB_WRITE_ERROR, #new
DIR_ERROR, #new
FILE_ERROR, #new
SUCCESS, #new
)
...
def init_app(db_path: str) -> int:
"""Initialize the application."""
config_code = _init_config_file()
if config_code != SUCCESS:
return config_code
database_code = _create_database(db_path)
if database_code != SUCCESS:
return database_code
return SUCCESS
def _init_config_file() -> int:
try:
CONFIG_DIR_PATH.mkdir(exist_ok=True)
except OSError:
return DIR_ERROR
try:
CONFIG_FILE_PATH.touch(exist_ok=True)
except OSError:
return FILE_ERROR
return SUCCESS
def _create_database(db_path: str) -> int:
config_parser = configparser.ConfigParser()
config_parser["General"] = {"database": db_path}
try:
with CONFIG_FILE_PATH.open("w") as file:
config_parser.write(file)
except OSError:
return DB_WRITE_ERROR
return SUCCESS
We have created init_app
function with two helper function _init_config_file
and _create_database
.
The init_app method will initialize the application using two helper functions.
The first helper function _init_config_file will create the project configuration file. If everything goes well it will return the SUCCESS code. and if not it will return the proper error code DIR_ERROR or FILE_ERROR.
The Second helper function _create_database will take the database location path string.
and create the database for our to-dos.
on success, it will return SUCCESS code and on error, it will return the DB_WRITE_ERROR code.
Our project config setup is complete.
Now let's code the database.py
module for our to-do database functionality.
Database Module
Create a file named database.py
inside todo
folder
# todo_cli/todo/database.py
> touch todo_cli/todo/database.py
This module will contain four parts which are
Default database file path - this will be used if no database file path is provided.
database initializer - to initialize the database with some value for example [] Empty to-do list.
get database path helper - to get the current path to the to-do database.
DatabaseHandler - to read and write to-dos to the database.
We will code the first three parts in the next section and the last one in the coming section.
First, we will need to import some modules and classes.
import configparser
from pathlib import Path
from todo import DB_WRITE_ERROR, SUCCESS, DB_READ_ERROR, JSON_ERROR
We will create a default database file path and use it if a custom path is not provided.
DEFAULT_DB_FILE_PATH = Path.home().joinpath(
"." + Path.home().stem + "_todo.json"
)
database initializer will try to initialize the database with an empty to-do list.
def init_database(db_path: Path) -> int:
"""Create the to-do database."""
try:
db_path.write_text("[]") # Empty to-do list
return SUCCESS
except OSError:
return DB_WRITE_ERROR
database path helper, it will read the current path of the database from our project config file.
def get_database_path(config_file: Path) -> Path:
"""Return the current path to the to-do database."""
config_parser = configparser.ConfigParser()
config_parser.read(config_file)
return Path(config_parser["General"]["database"])
DatabaseHandler for reading and writing to the database.
...
import json
from typing import Any, Dict, List, NamedTuple
...
class DBResponse(NamedTuple):
todo_list: List[Dict[str, Any]]
error: int
class DatabaseHandler:
def __init__(self, db_path: Path) -> None:
self._db_path = db_path
def read_todos(self) -> DBResponse:
try:
with self._db_path.open("r") as db:
try:
return DBResponse(json.load(db), SUCCESS)
except json.JSONDecodeError: # Catch wrong JSON format
return DBResponse([], JSON_ERROR)
except OSError: # Catch file IO problems
return DBResponse([], DB_READ_ERROR)
def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse:
try:
with self._db_path.open("w") as db:
json.dump(todo_list, db, indent=4)
return DBResponse(todo_list, SUCCESS)
except OSError: # Catch file IO problems
return DBResponse(todo_list, DB_WRITE_ERROR)
Here we have imported a couple of things JSON
to work with JSON data and type hints from the typing
module.
DBResponse class to return named tuple in response. named tuples will allow us to create named tuple with type hints for their named fields. here we have to fields todo_list
and error
so it will return something like this,
>>> from todo import database
>>> res = database.DBResponse(todo_list=[{'Description': 'Write a blog on creating your first CLI Application with typer.', 'Priority': 2, 'Done': False}], error=0)
# we can access tuple value for a specific field like this,
>>> res.todo_list
DatabaseHandler class have two methods,
- read_todos - to read to-dos from the database and return a response with type
DBResponse
- write_todos - it will take a new to-do list and write it to the database.
The database module is completed. to communicate with the database we will need a controller and in the next section, we will code the todo controller.
To-do Controller
Create a file named todo.py
inside todo
folder
# todo_cli/todo/todo.py
> touch todo_cli/todo/todo.py
We will create one class with the name Todoer
and add the following methods to it.
- add - Create todo
- get_todo_list- Read todo
- set_done - Update todo
- remove - Delete todo
Let's start coding our controller by importing the required modules and classes.
from pathlib import Path
from typing import Any, Dict, NamedTuple, List
from todo.database import DatabaseHandler
from todo import DB_READ_ERROR, ID_ERROR
As we have created DBResponse class
for the database to return a consistent response, similarly we will need to create CurrentTodo class
for todo controller response.
CurrentTodo will contain two fields named todo and error.
...
class CurrentTodo(NamedTuple):
todo: Dict[str, Any]
error: int
Now let's write code for our Todoer class.
...
class Todoer:
def __init__(self, db_path: Path) -> None:
self._db_handler = DatabaseHandler(db_path)
def add(self, description: List[str], priority: int = 2) -> CurrentTodo:
"""Add a new to-do to the database."""
pass
def get_todo_list(self) -> List[Dict[str, Any]]:
"""Return the current to-do list."""
pass
def set_done(self, todo_id: int) -> CurrentTodo:
"""Set a to-do as done."""
pass
def remove(self, todo_id: int) -> CurrentTodo:
"""Remove a to-do from the database using its id or index."""
pass
For now, we have added the pass keyword for each method, we will code each method business logic one by one.
add method it will accept 2 parameter namely description
and priority
. then we will join the description with a space character and check if the description is ending with .
or not, if not will add .
at the end.
Then we will create a todo
object with the property "Description", "Priority" and "Done". we will set Done
as false initially.
Next, we will read to-dos from the database and check for error handling, if everything goes well we will append the new todo to the existing todo list and write it back to the database.
...
def add(self, description: List[str], priority: int = 2) -> CurrentTodo:
"""Add a new to-do to the database."""
description_text = " ".join(description)
if not description_text.endswith("."):
description_text += "."
todo = {
"Description": description_text,
"Priority": priority,
"Done": False,
}
read = self._db_handler.read_todos()
if read.error == DB_READ_ERROR:
return CurrentTodo(todo, read.error)
read.todo_list.append(todo)
write = self._db_handler.write_todos(read.todo_list)
return CurrentTodo(todo, write.error)
get_todo_list first it will read the to-dos from the database using the read_todos method and store the response in the read variable then it will return the read.todo_list
.
...
def get_todo_list(self) -> List[Dict[str, Any]]:
"""Return the current to-do list."""
read = self._db_handler.read_todos()
return read.todo_list
set_done will accept todo id and set corresponding todo as done if it exists, otherwise throw an error.
...
def set_done(self, todo_id: int) -> CurrentTodo:
"""Set a to-do as done."""
read = self._db_handler.read_todos()
if read.error:
return CurrentTodo({}, read.error)
try:
todo = read.todo_list[todo_id - 1]
except IndexError:
return CurrentTodo({}, ID_ERROR)
todo["Done"] = True
write = self._db_handler.write_todos(read.todo_list)
return CurrentTodo(todo, write.error)
remove will accept todo id and delete the corresponding todo if it exists, otherwise throw an error.
...
def remove(self, todo_id: int) -> CurrentTodo:
"""Remove a to-do from the database using its id or index."""
read = self._db_handler.read_todos()
if read.error:
return CurrentTodo({}, read.error)
try:
todo = read.todo_list.pop(todo_id - 1)
except IndexError:
return CurrentTodo({}, ID_ERROR)
write = self._db_handler.write_todos(read.todo_list)
return CurrentTodo(todo, write.error)
If you have made till here, awesome 😍👨🏻💻.
Now we will code the CLI module, This module will contain CLI commands for our to-do application. it will also contain one helper function that will help our application to get a to-do controller.
CLI Module
Create a file named cli.py
inside todo
folder
# todo_cli/todo/cli.py
> touch todo_cli/todo/cli.py
Let's start with importing some modules and classes.
from pathlib import Path
# Type hints from typing
from typing import Optional, List
# Typer module
import typer
# importing other modules config, database and todo
from todo import __app_name__, __version__, ERRORS, config, database, todo
To work with typer we will need to initialize the application using the Typer
class from the typer module.
...
# initializing todo_cli, you can give any name you like.
todo_cli = typer.Typer()
When we create a todo_cli = typer.Typer()
, it will work as a group of commands. And you can create multiple commands with it.
Each of those commands can have its own CLI parameters.
But as those CLI parameters are handled by each of those commands, they don't allow us to create CLI parameters for the main CLI application itself.
But we can use @todo_cli.callback()
for that. it declares the CLI parameters for the main CLI application.
...
def callback(value: bool) -> None:
if value:
typer.echo(f"{__app_name__} v{__version__}")
raise typer.Exit()
@todo_cli.callback()
def main(
version: Optional[bool] = typer.Option(
None,
"--version",
"-v",
help="Show the application's version and exit.",
callback=callback,
is_eager=True,
)
) -> None:
return
Here we have two functions
- callback - It will get the boolean value and if the value is true it will print the app name with the version.
- main - Get executed when we run our CLI with some initial parameters like
-v
,--version
and--help
.
To see this thing in action let's create the __main__.py
file in our todo folder and make our todo
package an executable program.
Create a file named __main__.py
inside todo
folder
# todo_cli/todo/__main__.py
> touch todo_cli/todo/__main__.py
Add this code to the __main__.py
file.
# importing CLI module and app name
from todo import cli, __app_name__
# calling typer app that is **todo_cli** with **prog_name** to ensure our application get's correct app name.
def main():
cli.todo_cli(prog_name=__app_name__)
if __name__ == "__main__":
main()
Our minimal CLI is ready and we can run these commands to see things in action.
Get application version
> python3 -m todo -v
OR
> python3 -m todo --version
# Output - it will print the app name with the version.
todo v0.1.0
Get help
> python3 -m todo --help
# Output - it will print the available options and commands.
Usage: todo [OPTIONS] COMMAND [ARGS]...
Options:
-v, --version Show the application's version and exit.
--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:
...
This is amazing right, so let's add some commands to add, read, update and delete to-dos.
Before adding commands we will need to add the helper function that will help us to communicate with our todo database using the todo controller.
...
def get_todoer() -> todo.Todoer:
if config.CONFIG_FILE_PATH.exists():
db_path = database.get_database_path(config.CONFIG_FILE_PATH)
else:
typer.secho(
'Config file not found. Please, run "todo init"',
fg=typer.colors.RED,
)
raise typer.Exit(1)
if db_path.exists():
return todo.Todoer(db_path)
else:
typer.secho(
'Database not found. Please, run "todo init"',
fg=typer.colors.RED,
)
raise typer.Exit(1)
This helper will check if the application config is present or not. if present it will get the database path and return the Todoer
controller instance.
If any of the checks fail it will echo the error using the typer.secho()
.
typer.secho() - It is the same as an echo but it will also apply the style i.e style and echo.
init command
...
@todo_cli.command()
def init(
db_path: str = typer.Option(
str(database.DEFAULT_DB_FILE_PATH),
"--db-path",
"-db",
prompt="to-do database location?",
),
) -> None:
"""Initialize the to-do database."""
app_init_error = config.init_app(db_path)
if app_init_error:
typer.secho(
f'Creating config file failed with "{ERRORS[app_init_error]}"',
fg=typer.colors.RED,
)
raise typer.Exit(1)
db_init_error = database.init_database(Path(db_path))
if db_init_error:
typer.secho(
f'Creating database failed with "{ERRORS[db_init_error]}"',
fg=typer.colors.RED,
)
raise typer.Exit(1)
else:
typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN)
Here we are using command decorator to make init function as init command that will accept database location.
typer.Option() method will provide an option to pass cli parameters. here we are passing four params,
- str(database.DEFAULT_DB_FILE_PATH) - default database path
- "--db-path" - param to pass database location
- "-db" - param to pass database location
- prompt="to-do database location?" - prompt to ask for database location.
Let's initialize our application with the init
command.
Example :
> python3 -m todo init
# Output
# prompt for database location, if you leave it empty and `Enter` it will take the default path,
to-do database location? [/Users/username/.username_todo.json]:
# after entering you will get output like this
The to-do database is /Users/username/.username_todo.json
add command
...
@todo_cli.command()
def add(
description: List[str] = typer.Argument(...),
priority: int = typer.Option(2, "--priority", "-p", min=1, max=3),
) -> None:
"""Add a new to-do with a DESCRIPTION."""
todoer = get_todoer()
todo, error = todoer.add(description, priority)
if error:
typer.secho(
f'Adding to-do failed with "{ERRORS[error]}"', fg=typer.colors.RED
)
raise typer.Exit(1)
else:
typer.secho(
f"""to-do: "{todo['Description']}" was added """
f"""with priority: {priority}""",
fg=typer.colors.GREEN,
)
add command will accept two parameter
- description - description for the todo as an argument
- priority - as an option with default value 2.
typer.Argument(...) is used for required values. typer.Option() is used for Optional values.
get_todoer() helper will return the controller instance and then we will call the add
method of the controller with description and priority. it will return the named tuple with fields todo and error.
if an error then we will print the error message using that error code with the corresponding text colour. else we will print the message that to-do was added.
Example :
> python3 -m todo add "write an article on building CLI application with typer " --priority 2
# Output
to-do: "write an article on building CLI application with typer." was added with priority: 2
list command
This command will read all the to-dos from the database and print all the to-dos with some style.
...
@todo_cli.command(name="list")
def list_all() -> None:
"""List all to-dos."""
todoer = get_todoer()
todo_list = todoer.get_todo_list()
if len(todo_list) == 0:
typer.secho(
"There are no tasks in the to-do list yet", fg=typer.colors.RED
)
raise typer.Exit()
typer.secho("\nto-do list:\n", fg=typer.colors.CYAN, bold=True)
typer.secho(str(todo_list), fg=typer.colors.CYAN, bold=True)
We can pass the name param to the command decorator to specify the command name. it is optional but here we are passing list as a name.
Example :
> python3 -m todo list
complete command
This command will take the todo id and mark it as complete.
...
@todo_cli.command()
def complete(todo_id: int = typer.Argument(...)) -> None:
"""Complete a to-do by setting it as done using its TODO_ID."""
todoer = get_todoer()
todo, error = todoer.set_done(todo_id)
if error:
typer.secho(
f'Completing to-do # "{todo_id}" failed with "{ERRORS[error]}"',
fg=typer.colors.RED,
)
raise typer.Exit(1)
else:
typer.secho(
f"""to-do # {todo_id} "{todo['Description']}" completed!""",
fg=typer.colors.GREEN,
)
We will use this command to update our todo with todo_id. here we have made todo_id as the required argument and if we don't pass the id it will give an error Missing argument 'TODO_ID'.
Example :
> python3 -m todo complete 1
# Output
to-do # 1 "write an article on building CLI application with typer." completed!
Till now we have added commands for adding, reading and updating the todo. let's add the command to delete the todo.
delete command
This command will take the todo id and the option force to delete the todo without confirmation.
...
@todo_cli.command()
def delete(
todo_id: int = typer.Argument(...),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Force deletion without confirmation.",
),
) -> None:
"""Delete a to-do using its TODO_ID."""
todoer = get_todoer()
def _delete():
todo, error = todoer.remove(todo_id)
if error:
typer.secho(
f'Removing to-do # {todo_id} failed with "{ERRORS[error]}"',
fg=typer.colors.RED,
)
raise typer.Exit(1)
else:
typer.secho(
f"""to-do # {todo_id}: '{todo["Description"]}' was removed""",
fg=typer.colors.GREEN,
)
if force:
_delete()
else:
todo_list = todoer.get_todo_list()
try:
todo = todo_list[todo_id - 1]
except IndexError:
typer.secho("Invalid TODO_ID", fg=typer.colors.RED)
raise typer.Exit(1)
delete = typer.confirm(
f"Delete to-do # {todo_id}: {todo['Description']}?"
)
if delete:
_delete()
else:
typer.echo("Operation cancelled")
Here we have created the _delete()
helper function that will use the controller instance remove method to execute the remove logic.
It will return a named tuple with fields todo and error.
If an error then it will print the error message with the error code else it will print the message todo was removed.
If force is true then we will directly call the _delete()
helper and if not then we will ask for confirmation using typer.confirm() method.
If the confirmed response is true then we will call the _delete()
helper and if not then we will print the message Operation cancelled
Example :
> python3 -m todo delete 1
# Output
Delete to-do # 1: write an article on building CLI application with typer.? [y/N]: N
Operation cancelled
> python3 -m todo delete 1
# Output
Delete to-do # 1: write an article on building CLI application with typer.? [y/N]: y
to-do # 1: 'write an article on building CLI application with typer.' was removed
Congratulations 🎉😍, we have successfully built our first CLI application with Typer and Python.
Summary
- We discussed what is typer and its feature.
- Did setup for todo CLI application
- Coded different modules for our application namely config, database, controller and CLI.
- We discussed the typer callback() decorator.
- We Discussed typer Option() and Argument() methods
- We Discussed typer command decorator with or without name param
- Added different commands to our CLI init, add, list, complete and delete.
Conclusion
Typer is a great python library that allows us to build a CLI application with minimal effort and less code. we can use typer to build simple as well as complex CLI applications. give it a try and let me know in the comments what kind of CLI you have built with typer.
And that’s it for this topic. Thank you for reading.