Adventures in Machine Learning

Building a Scalable To-Do App with Python and Typer

Creating a to-do application from scratch may seem like a daunting task, but in reality, it can be a fun and rewarding project that teaches valuable skills. In this article, we will cover the essential elements of building a to-do application using Python and Typer, a powerful command-line interface (CLI) framework.

Project Overview

A to-do application is a program that helps people keep track of their tasks and deadlines. Our application will use the Model-View-Controller pattern, which separates concerns between data, presentation, and user interaction.

We will define a data type that represents our to-do items and implement a persistent storage mechanism to save and retrieve them. The user interface will be a command-line interface that accepts input and displays output.

Tasks Involved

For this project, we will utilize the Model-View-Controller (MVC) pattern, which separates concerns into three main parts: Model, View, and Controller. This architecture ensures that each part of the application is responsible for a specific aspect of functionality and makes it easy to maintain and scale.

1. Model

The Model will define the data type for our to-do items and include the functions for saving and retrieving them from persistent storage. This will involve using named tuples and dictionaries, as well as the json module to serialize and deserialize our data to JSON format.

2. View

The View will be responsible for displaying our to-do items and providing a user-friendly interface. This will utilize the CLI framework Typer and the Colorama library for colored text.

3. Controller

The Controller will handle the user input and trigger actions in the Model and View. We will use the configparser module to manage user configuration settings, and the argparse module to parse command-line arguments for our application.

Tools and Libraries Used

To get started, we need a working Python environment. We will create a virtual environment using venv and specify our dependencies using a requirements.txt file.

Our main tool for building the CLI interface is Typer, which simplifies creating commands and options. We will also use named tuples to define our to-do item data structure, dictionaries to store our data, and the json module to serialize and deserialize it.

Additionally, we will use configparser to manage user configuration settings, pytest for testing, and argparse for parsing command-line arguments.

Set Up the To-Do Project

Now that we have a clear idea of what we want to build and the tools and libraries we will use, it’s time to set up our project. We will start by creating a directory structure that includes a root directory, packages, modules, files, a rptodo directory for our application code, a tests directory for our tests, and a README.md file to document our project.

Inside the rptodo directory, we will create separate modules for our Model, View, and Controller.

Conclusion

By following these steps, we can build a fully functional to-do application using Typer and Python. Utilizing the Model-View-Controller pattern ensures that our application is well-structured, scalable, and maintainable.

Remember to document your project and write tests to ensure the correctness of your code. With this project as a solid foundation, you can further customize your to-do application to fit your personal needs.

3) Set Up the To-Do CLI App With Python and Typer

Typer is a Python library that makes it easy to create command line interfaces. It provides a clean and intuitive syntax for defining options, commands, and arguments that your application can accept.

With Typer, you can quickly create a CLI application with minimal boilerplate code. Let’s dive in and see what it takes to create a to-do CLI app using Typer.

Importing necessary libraries

Before we can start creating our Typer CLI app, we need to import the necessary libraries. These libraries are typing and Typer.

The typing module provides support for type hints, which help to improve the readability of our code by specifying the expected type of a variable or function return value. The Typer module makes it easy to define our CLI application by providing a clean and intuitive syntax.

Creating a minimal Typer CLI application

With the necessary libraries imported, we can now create our Typer CLI app. To create an explicit Typer application, we need to define a function decorated with the typer.Typer object.

This function will contain all the commands and options for our to-do app. Let’s start with a minimal example.

import typer
app = typer.Typer()
@app.command()
def hello():
    typer.echo("Hello, World!")
if __name__ == "__main__":
    app()

In this example, we define a `hello` command that simply echos “Hello, World!” to the terminal when invoked. We use the `app.command()` decorator to define the `hello` command, and `typer.echo()` to print the message.

We can run this application by executing the script with Python. When we run the script, Typer will detect the `__main__` module and execute the `app()` function, which will invoke our command.

Typer provides several other features that we can use to improve our CLI app, such as options and arguments. Let’s take a look at how we can use options and arguments to make our to-do app more functional.

To add options and arguments, we simply add them as function parameters to our command functions. Typer allows us to specify the type of the option or argument, as well as some optional configuration parameters like help text and default values.

import typer
app = typer.Typer()
@app.command()
def add_task(task: str, due_date: str):
    typer.echo(f"Added: {task} (Due: {due_date})")
if __name__ == "__main__":
    app()

In this example, we define an `add_task` command that accepts two string arguments: `task` and `due_date`. When we call the `add_task` command with two arguments, it will print a message to the terminal indicating that the task was added.

We can now run our to-do app by executing the script with Python and invoking the `add_task` command. “`

python todo.py add_task "Buy groceries" "2022-12-31"

This will add the “Buy groceries” task to our to-do list with a due date of December 31, 2022.

4) Prepare the To-Do Database for Use

Application’s configurations

We now have the basic structure of our CLI app set up, but we need to configure it to use a to-do database to store our tasks. To do this, we will use the configparser module to create a configuration file for our app.

The configuration file will contain the path to the to-do database file, which our app will use to store and retrieve tasks. “`

import configparser
config = configparser.ConfigParser()
config["todo"] = {"db_path": "/path/to/todo.json"}
with open("config.ini", "w") as f:
    config.write(f)

In this example, we create a `config` object using ConfigParser and set the `db_path` value to the path of our to-do database file. We then write this configuration to a file called `config.ini`.

To-Do database set up

Now that we have our configuration file set up, we can create a to-do database file. For this example, we will use the json module to create a JSON file to store our tasks.

We can create a command that initializes our database file when our app starts. “`

import typer
import json

import configparser
app = typer.Typer()
config = configparser.ConfigParser()
config.read("config.ini")
db_path = config["todo"]["db_path"]
@app.callback()
def callback():
    """
    This command initializes our to-do database file if it doesn't exist.     """
    try:
        with open(db_path, "x") as f:
            json.dump([], f)
            typer.echo(f"To-Do database created at {db_path}")
    except FileExistsError:
        pass
@app.command()
def add_task(task: str, due_date: str):
    with open(db_path, "r+") as f:
        data = json.load(f)
        data.append({"task": task, "due_date": due_date})
        f.seek(0)
        json.dump(data, f)
    typer.echo(f"Added: {task} (Due: {due_date})")
if __name__ == "__main__":
    app()  

In this example, we read the configuration file and get the value for `db_path`.

We define a `callback` function that initializes our database file using a try-except block. If the file exists, it simply passes.

If it does not exist, it creates it using the `json.dump` method and then prints a message that the file was created. Our `add_task` command now reads the database using a `with open` block, fetches the existing data, appends the new data to the list, writes it to the file, and then prints a success message.

Now we have a fully functional to-do application that can store tasks in a JSON file. These examples only scratch the surface of what Typer can do.

With a little creativity, we can create powerful and intuitive CLI applications that can make our lives easier.

5) Set Up the To-Do App Back End

In the previous sections, we have set up the project structure and created a basic Typer CLI app. In this section, we will define the to-do item, communicate with the CLI commands, and manage the database.

We will also create a controller class, Todoer, to handle the business logic and functionality.

Defining a Single To-Do

Before we start building the controller class, we need to define what a single to-do item looks like. We can define a to-do item as a dictionary or named tuple.

In this example, we will use a named tuple. “`

from typing import NamedTuple
class TodoItem(NamedTuple):
    task: str
    done: bool = False

This named tuple has two fields: task and done. The task field is a mandatory string, and the done field is an optional boolean that defaults to False.

We will use this named tuple throughout our code to represent a single to-do item.

Communicating with CLI

Our to-do app communicates with the CLI through Typer’s main function. We will define three commands: add, list, and complete.

The add command will be used to add a new task to the to-do list, the list command will be used to display the current to-do list, and the complete command will be used to mark a task as done. “`

import typer
app = typer.Typer()
@app.command()
def add(task: str):
    typer.echo(f"Added {task}")
@app.command()
def list():
    typer.echo("To-Do List")
@app.command()
def complete(index: int):
    typer.echo(f"Completed task at index {index}")
if __name__ == "__main__":
    app()

In this example, we define three commands: add, list, and complete. The add command takes a string argument, which is the task we want to add to the list.

The list command takes no arguments and simply displays the current to-do list. The complete command takes an integer argument, which is the index of the task we want to mark as done.

Communicating with the database

We will use a JSON file to store our to-do list. We need two functions to read and write to the JSON file.

import json
from typing import List
from pathlib import Path
def read_todos() -> List[TodoItem]:
    todos = []
    path = Path("todos.json")
    if path.exists():
        with path.open() as f:
            todos = [TodoItem(**item) for item in json.load(f)]
    return todos
def write_todos(todos: List[TodoItem]):
    with open("todos.json", "w") as f:
        json.dump([todo._asdict() for todo in todos], f, indent=4)

The `read_todos` function reads the contents of the JSON file and returns the to-do list as a list of TodoItem named tuples. The `write_todos` function takes a list of TodoItems as an argument and writes it to the JSON file.

Writing the controller class, Todoer

To encapsulate the business logic of our to-do app, we will create a controller class called Todoer. This class will have methods for adding, listing, and completing tasks.

class Todoer:
    def add(self, task: str):
        todos = read_todos()
        todos.append(TodoItem(task=task))
        write_todos(todos)
    def list(self):
        todos = read_todos()
        for i, todo in enumerate(todos):
            status = "done" if todo.done else "not done"
            print(f"{i + 1}. {todo.task} - {status}")
    def complete(self, index: int):
        todos = read_todos()
        try:
            todo = todos[index - 1]
            todos[index - 1] = todo._replace(done=True)
            write_todos(todos)
        except IndexError:
            print("Invalid index.")

In this example, we define three methods: add, list, and complete.

The add method takes a task string, creates a new TodoItem named tuple, adds it to the to-do list, and writes the updated list to the JSON file. The list method reads the to-do list, prints each task and its status, and displays it to the user.

The complete method takes an index integer, marks the task at that index as done, and writes the updated list to the JSON file.

6) Code the Adding and Listing To-Dos Functionalities

Now that we have defined the controller class, we can implement the add and list commands in our Typer CLI app. Defining Unit Tests for Todoer.add()

To ensure the correctness of our code, we should write unit tests.

We will define a test_rptodo.py file in the tests directory and use the pytest framework to execute our tests. “`

import pytest
from rptodo.todoer import Todoer
def test_add():
    todoer = Todoer()
    todoer.add("Buy milk")
    todos = todoer.list()
    assert len(todos) == 1
    assert todos[0].task == "Buy milk"

In this example, we define a test for the add method of the Todoer class. We create a new Todoer object, add a task to it, retrieve the to-do list, and assert that the task was added correctly.

Implementing the add CLI Command

Now that we have our add method and unit tests defined, we can implement the add command in our Typer CLI app. “`

@app.command()
def add(task: str):
    todoer = Todoer()
    todoer.add(task)

In this example, we define the add command function, which takes a single string argument, `task`.

We create a new Todoer object and call its add method with the task argument.

Implementing the list Command

The last command that we will implement is the list command. “`

@app.command()
def list():
    todoer = Todoer()
    todos = todoer.list()
    for todo in todos:
        status = "done" if todo.done else "not done"
        typer.echo(f"{todo.task} - {status}")

In this example, we define the list command function, which takes no arguments.

We create a new Todoer object and call its list method, which returns a list of TodoItem named tuples. We print the task and status of each item in the list using Typer’s echo function.

Conclusion

In this article, we have covered the basics of building a to-do app with Python

Popular Posts