Skip to content

Create a Task Tracker App for the Terminal with Python (Rich, Typer, Sqlite3)

In this Python Tutorial we learn how to build a terminal application (CLI app) to manage our tasks and todos.


In this Python Tutorial we learn how to build a terminal application (CLI app) to manage our tasks and todos. We use Typer for building the CLI app, Rich for a colorized terminal output, and SQLite for the database.

Tech Stack:

  • Rich: Rich text and beautiful formatting in the terminal
  • Typer: Let's you build great CLIs that are easy to code
  • SQLite3: Simple database

Installation

We can install Rich and Typer with pip. SQLite3 is a built-in Python core module:

$ pip install rich typer

Get Started With Typer

First we setup Typer to create the CLI (Command Line Interface).

Create a new file todocli.py. Then create different functions to add, delete, update, complete, and show the todos.

Each todo should get a task name and a category.

import typer

app = typer.Typer()


@app.command(short_help='adds an item')
def add(task: str, category: str):
    typer.echo(f"adding {task}, {category}")


@app.command()
def delete(position: int):
    typer.echo(f"deleting {position}")


@app.command()
def update(position: int, task: str = None, category: str = None):
    typer.echo(f"updating {position}")


@app.command()
def complete(position: int):
    typer.echo(f"complete {position}")


@app.command()
def show():
    typer.echo(f"Todos")


if __name__ == "__main__":
    app()

Now we can run the file in the terminal and automatically have support for all commands. For example try this:

$ python todocli.py --help
$ python todocli.py add --help
$ python todocli.py add "My first todo" "Study"

Try this for the other commands yourself!

Add Rich For Terminal Styling

We want to display the todos in a beautiful looking table layout with different colors. For this setup Rich and then modify the show() function:

from rich.console import Console
from rich.table import 

console = Console()

@app.command()
def show():
    tasks = [("My first todo", "Study", ("My second todo", "Sports")]

    console.print("[bold magenta]Todos[/bold magenta]!", "💻")

    table = Table(show_header=True, header_style="bold blue")
    table.add_column("#", style="dim", width=6)
    table.add_column("Todo", min_width=20)
    table.add_column("Category", min_width=12, justify="right")
    table.add_column("Done", min_width=12, justify="right")

    def get_category_color(category):
        COLORS = {'Learn': 'cyan', 'YouTube': 'red', 'Sports': 'cyan', 'Study': 'green'}
        if category in COLORS:
            return COLORS[category]
        return 'white'

    for idx, task in enumerate(tasks, start=1):
        c = get_category_color(task[1])
        status = False
        is_done_str = '✅' if status else '❌'
        table.add_row(str(idx), task[0], f'[{c}]{task[1]}[/{c}]', is_done_str)
    console.print(table)


if __name__ == "__main__":
    app()

For now we hard-coded the todos and its attributes. We also setup a dictionary with predefined categories and its corresponding colors.

Now run the show command and see how it looks:

$ python todocli.py show

Create A Todo Model

Now we want to setup an actual todo class.

Create a new file model.py and add the following:

import datetime


class Todo:
    def __init__(self, task, category, 
                 date_added=None, date_completed=None,
                 status=None, position=None):
        self.task = task
        self.category = category
        self.date_added = date_added if date_added is not None else datetime.datetime.now().isoformat()
        self.date_completed = date_completed if date_completed is not None else None
        self.status = status if status is not None else 1  # 1 = open, 2 = completed
        self.position = position if position is not None else None

    def __repr__(self) -> str:
        return f"({self.task}, {self.category}, {self.date_added}, {self.date_completed}, {self.status}, {self.position})"

Next to the task and category, it also gets fields for the created and completed date, a status to see if it's completed or not, and a position index.

Create The Database

Now we setup the SQLite database. Create a new table named "todos" with the same fields as the model class contains. Then implement all required functions to add and modify the todos.

Create a new file database.py and add the following:

import sqlite3
from typing import List
import datetime
from model import Todo

conn = sqlite3.connect('todos.db')
c = conn.cursor()


def create_table():
    c.execute("""CREATE TABLE IF NOT EXISTS todos (
            task text,
            category text,
            date_added text,
            date_completed text,
            status integer,
            position integer
            )""")


create_table()


def insert_todo(todo: Todo):
    c.execute('select count(*) FROM todos')
    count = c.fetchone()[0]
    todo.position = count if count else 0
    with conn:
        c.execute('INSERT INTO todos VALUES (:task, :category, :date_added, :date_completed, :status, :position)',
        {'task': todo.task, 'category': todo.category, 'date_added': todo.date_added,
         'date_completed': todo.date_completed, 'status': todo.status, 'position': todo.position })


def get_all_todos() -> List[Todo]:
    c.execute('select * from todos')
    results = c.fetchall()
    todos = []
    for result in results:
        todos.append(Todo(*result))
    return todos


def delete_todo(position):
    c.execute('select count(*) from todos')
    count = c.fetchone()[0]

    with conn:
        c.execute("DELETE from todos WHERE position=:position", {"position": position})
        for pos in range(position+1, count):
            change_position(pos, pos-1, False)


def change_position(old_position: int, new_position: int, commit=True):
    c.execute('UPDATE todos SET position = :position_new WHERE position = :position_old',
                {'position_old': old_position, 'position_new': new_position})
    if commit:
        conn.commit()


def update_todo(position: int, task: str, category: str):
    with conn:
        if task is not None and category is not None:
            c.execute('UPDATE todos SET task = :task, category = :category WHERE position = :position',
                      {'position': position, 'task': task, 'category': category})
        elif task is not None:
            c.execute('UPDATE todos SET task = :task WHERE position = :position',
                      {'position': position, 'task': task})
        elif category is not None:
            c.execute('UPDATE todos SET category = :category WHERE position = :position',
                      {'position': position, 'category': category})


def complete_todo(position: int):
    with conn:
        c.execute('UPDATE todos SET status = 2, date_completed = :date_completed WHERE position = :position',
                  {'position': position, 'date_completed': datetime.datetime.now().isoformat()})

Put Everything Together

Now we put everything together and use these database functions in the todocli.py.

Import all functions and call them in the corresponding command functions. Also replace the hardcoded todos in the show function with the actual todos.

This is the final todocli.py:

import typer
from rich.console import Console
from rich.table import Table
from model import Todo
from database import get_all_todos, delete_todo, insert_todo, complete_todo, update_todo

console = Console()

app = typer.Typer()


@app.command(short_help='adds an item')
def add(task: str, category: str):
    typer.echo(f"adding {task}, {category}")
    todo = Todo(task, category)
    insert_todo(todo)
    show()

@app.command()
def delete(position: int):
    typer.echo(f"deleting {position}")
    # indices in UI begin at 1, but in database at 0
    delete_todo(position-1)
    show()

@app.command()
def update(position: int, task: str = None, category: str = None):
    typer.echo(f"updating {position}")
    update_todo(position-1, task, category)
    show()

@app.command()
def complete(position: int):
    typer.echo(f"complete {position}")
    complete_todo(position-1)
    show()

@app.command()
def show():
    tasks = get_all_todos()
    console.print("[bold magenta]Todos[/bold magenta]!", "💻")

    table = Table(show_header=True, header_style="bold blue")
    table.add_column("#", style="dim", width=6)
    table.add_column("Todo", min_width=20)
    table.add_column("Category", min_width=12, justify="right")
    table.add_column("Done", min_width=12, justify="right")

    def get_category_color(category):
        COLORS = {'Learn': 'cyan', 'YouTube': 'red', 'Sports': 'cyan', 'Study': 'green'}
        if category in COLORS:
            return COLORS[category]
        return 'white'

    for idx, task in enumerate(tasks, start=1):
        c = get_category_color(task.category)
        is_done_str = '✅' if task.status == 2 else '❌'
        table.add_row(str(idx), task.task, f'[{c}]{task.category}[/{c}]', is_done_str)
    console.print(table)


if __name__ == "__main__":
    app()

Congratulations! Now you should have a fully functioning CLI app. Now go ahead and try different commands to modify your own todo list!

The code is also available on GitHub.

Next Steps

The app now contains the basic functionality. Here are different ideas how you can now improve this:

  • The date fields are left unused so far, but of course they can be used for display or for internal analytics.
  • Implement a way to display open and finished todos separately.
  • Implement analytics.
  • Implement functionality to reorder the items (e.g. move a position).
  • Add another status value that means this todo is the current one you want to focus on.

The possibilities are endless! Let me know if you extend the functionality!

If you enjoyed this tutorial, please share it with your friends and on Twitter!


FREE VS Code / PyCharm Extensions I Use

✅ Write cleaner code with Sourcery, instant refactoring suggestions: Link*


Python Problem-Solving Bootcamp

🚀 Solve 42 programming puzzles over the course of 21 days: Link*

* These are affiliate link. By clicking on it you will not have any additional costs. Instead, you will support my project. Thank you! 🙏