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.

Build Your First CLI Application With Typer

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,

  1. read_todos - to read to-dos from the database and return a response with type DBResponse
  2. 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.

  1. add - Create todo
  2. get_todo_list- Read todo
  3. set_done - Update todo
  4. 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

image.png

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.

Connect with me

LinkedIn | Twitter

Did you find this article valuable?

Support Sachin Chaurasiya by becoming a sponsor. Any amount is appreciated!