loading...
Cover image for How to Make an Interactive Todo List CLI using Python with an Easy Login Mechanism
Cotter

How to Make an Interactive Todo List CLI using Python with an Easy Login Mechanism

putrikarunia profile image Putri Karunia ・10 min read

Build your own Todo List CLI with Python using packages like Click, PyInquirer, and Cotter in minutes!

There a ton of awesome packages that helps you build a beautiful CLI, some are covered in this article Building Beautiful Command Line Interfaces with Python.

We're going to use some of them to make a nice and interactive todo list CLI.
Python Todo List CLI<br>

The API

We're going to use a ready-made REST API that helps us store and update the todo list. The API is accessible on https://cottertodolist.herokuapp.com/ and you can find the source code and documentation at the Github Repo.

The CLI

To make the CLI, we'll use several packages:

Get the full code for this tutorial in Cotter's Github.

Let's start with a super simple CLI

In your project directory, let's start by making a virtualenv:

python -m venv venv/

Then activate the virtual environment with the source command:

source venv/bin/activate

Now let's install click to make our simple cli:

pip install click

Then make a file for our CLI – let's call it cli.py and we'll make a simple greet command.

# cli.py
import click

@click.group()
def main():
    """ Simple CLI that will greet you"""
    pass

@main.command()
@click.argument('name')
def greet(name):
    """This will greet you back with your name"""
    click.echo("Hello, " + name)


if __name__ == "__main__":
    main()

This is how it looks like when run:

❯ python cli.py greet Putri
Hello, Putri

Now let's build our Todo List CLI.

Let's check our Todo List API documentation. An example request to show all lists looks like this:

### Show all lists
GET http://localhost:1234/list
Authorization: Bearer <access_token>

As you can see our API requires an access_token to only allow owners to view and update the todo list.

Start with Registering/Logging-in our users to the API

The first thing we need to do is to authenticate our users and register it to the Todo list API. The API looks like this:

### Login or Register a user using Cotter's response
### From the Python SDK
POST http://localhost:1234/login_register
Content-Type: application/json

{
    "oauth_token": {
        "access_token": "eyJhbGciOiJF...",
        "id_token": "eyJhbGciOiJFUzI...",
        "refresh_token": "40011:ra78TcwB...",
        "expires_in": 3600,
        "token_type": "Bearer",
        "auth_method": "OTP"
    }
}

To do this, we'll use Cotter's Python SDK to generate the oauth_tokens then send it to the todo list API.

Logging-in with Cotter

First, install Cotter using:

pip install cotter

Then, let's update our cli.py to add a login function:

# cli.py
import click
import os
import cotter

# 1️⃣ Add your Cotter API KEY ID here
api_key = "<COTTER_API_KEY_ID>"

@click.group()
def main():
    """ A Todo List CLI """
    pass

@main.command()
def login():
    """Login to use the API"""
    # 2️⃣ Add a file called `cotter_login_success.html`
    # 3️⃣ Call Cotter's api to login
    port = 8080 # Select a port
    response = cotter.login_with_email_link(api_key, port)

    click.echo(response) 


if __name__ == "__main__":
    main()

You'll need an API_KEY_ID from Cotter, which you can get from the Dashboard.

Following the SDK instructions, you'll also need an HTML file called cotter_login_success.html in your project directory. Copy cotter_login_success.html from the SDK's example folder in Github. Your project folder should now contain 2 files:

project-folder
|- cli.py
|- cotter_login_success.html

Now let's run the CLI and try to log in. You should see something like this:

❯ python cli.py login
Open this link to login at your browser: https://js.cotter.app/app?api_key=abcdefgh-c318-4fc1-81ad-5cc8b69051e8&redirect_url=http%3A%2F%2Flocalhost%3A8080&state=eabgzskfrs&code_challenge=zBa9xK4sI7zpqvDZL8iAX9ytSo0JZ0O4gWWuVIKTXU0&type=EMAIL&auth_method=MAGIC_LINK

This should also open your browser where you can enter your email and login.

Opened Link to Login

Once you're done logging-in, you should see the following response in your terminal:

{
  "oauth_token": {
    "access_token": "eyJhbGciOiJFU...",
    "id_token": "eyJhbGciOiJF...",
    "refresh_token": "40291:czHCOxamERp1yA...Y",
    "expires_in": 3600,
    "token_type": "Bearer",
    "auth_method": "OTP"
  },
  "identifier": {...},
  "token": {...},
  "user": {...}
}

This is perfect for our /login_register API endpoint, so let's send the response from login to that endpoint inside our login function.

Register or Login the user to the todo list API

First, we need to install requests to call HTTP requests:

pip install requests

Update our login function inside cli.py to the following:

# cli.py
import requests

@main.command()
def login():
    """Login to use the API"""
    # Add a file called `cotter_login_success.html`
    # Call Cotter's api to login
    port = 8080 # Select a port
    response = cotter.login_with_email_link(api_key, port)

    # 1️⃣ Add your Cotter API KEY ID here
    url = 'https://cottertodolist.herokuapp.com/login_register'
    headers = {'Content-Type': 'application/json'}
    data = response
    resp = requests.post(url, json=data, headers=headers)

    if resp.status_code != 200:
        resp.raise_for_status()

    click.echo(resp.json()) 

You should now see a response {'user_id': '274825255751516680'} which means we have successfully registered or logged-in our user.

Storing the oauth_tokens for later use
We don't want to ask the user to login every time we need the access_token. Fortunately, Cotter already provides a function to easily store and get (and automatically refresh) the access_token from a file.

Update your login function by storing the token just before echoing the response:

# cli.py
from cotter import tokenhandler
token_file_name = "cotter_token.json"

@main.command()
def login():
    ...

    tokenhandler.store_token_to_file(response["oauth_token"], token_file_name)

    click.echo(resp.json()) 

Create a Todo List

Our create todo list API looks like this:

### Create a new Todo list
POST http://localhost:1234/list/create
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "name": "Web Repo"
}

Let's add a function called create in our cli.py below our login function

# cli.py
@main.command()
@click.option('--name', prompt='List name', help='Name for your new todo list')
def create(name):
    """Create a todo list"""
    # Get access token
    access_token = tokenhandler.get_token_from_file(token_file_name, api_key)["access_token"]

    # Construct request
    url = "https://cottertodolist.herokuapp.com/list/create"
    headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + access_token}
    data = { "name": name }
    response = requests.post(url, json=data, headers=headers)

    if response.status_code != 200:
        response.raise_for_status()

    click.echo("List " + name + " created!")

The function above takes an argument called --name (it'll ask you for the name of the list), then call the API with our access token. If you run it, it'll look like this:

❯ python cli.py create
List name: Web Repo
List Web Repo created!

Add a Task to the list

To add a task to a list, the API looks like this:

### Create a Task within a list
### name = List name, task = Task name/description
POST http://localhost:1234/todo/create
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "name": "Web Repo",   
    "task": "Update favicon.ico"
}

Since the user might have more than one list, we want to ask them to choose which list they want to use. We can use PyInquirer to make a nice prompt with multiple options. First, install PyInquirer:

pip install PyInquirer

Then add a function called add in our cli.py.

# cli.py
# Import PyInquirer prompt
from PyInquirer import prompt

@main.command()
def add():
    """Create a todo task for a list"""
    # Get access token from file
    access_token = tokenhandler.get_token_from_file(token_file_name, api_key)["access_token"]

    # Get all todo lists for the user
    url = "https://cottertodolist.herokuapp.com/list"
    headers = {'Authorization': 'Bearer ' + access_token}
    response = requests.get(url, headers=headers)
    lists = response.json()

    # Prompt to pick list
    options = map(lambda x: x["name"], lists)
    questions = [
      {
        'type': 'list',
        'name': 'list_name',
        'message': 'Add task to which list?',
        'choices': options,
      },
      {
        'type': 'input',
        'name': 'task_name',
        'message': 'Task description',
      }
    ]
    answers = prompt(questions)
    if not answers:
        return

    # Call API to create task fot the selected list
    url = "https://cottertodolist.herokuapp.com/todo/create"
    headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + access_token}
    data = {
      "name": answers['list_name'],
      "task": answers['task_name']
    }
    response = requests.post(url, json=data, headers=headers)
    if response.status_code != 200:
        response.raise_for_status()

    click.echo("Task " + answers['task_name'] + " is added in list " + answers['list_name'])
  • First, we needed to call the API to list all todo lists for the user.
  • The first question has type: list which asks the user to choose which list they want to add a new task to.
  • The second question has type: input which asks the user to type in the new task's description.
  • Using the answers, we call our API to add the task to the chosen list.

python cli.py add

Show our Todo Lists

Now that we've created a list and added some tasks, let's see the list! The API to see a list looks like this:

### Show all lists
GET http://localhost:1234/list
Authorization: Bearer <access_token>

Let's add a function called ls to list the todo lists for this user in cli.py. We'll apply this rule:

  • python cli.py ls will ask you which list you want to see, then show that list.
  • python cli.py ls -a will immediately show all your lists.
# cli.py

@main.command()
@click.option('-a', '--all', is_flag=True) # Make a boolean flag
def ls(all):
    """Show lists"""
    # Get access token from file
    access_token = tokenhandler.get_token_from_file(token_file_name, api_key)["access_token"]

    # Get all lists
    url = "https://cottertodolist.herokuapp.com/list"
    headers = {'Authorization': 'Bearer ' + access_token}
    response = requests.get(url, headers=headers)
    if response.status_code != 200:
        response.raise_for_status()
    listsFormatted = response.json()

    if all == True:
        # Show all tasks in all lists
        for chosenList in listsFormatted:
            click.echo('\n' + chosenList['name'])
            for task in chosenList['tasks']:
                if task['done'] == True:
                    click.echo("[✔] " + task['task'])
                else:
                    click.echo("[ ] " + task['task'])
    else:
        # Show a prompt to choose a list
        questions = [
            {
                'type': 'list',
                'name': 'list',
                'message': 'Which list do you want to see?',
                'choices': list(map(lambda lst: lst['name'], listsFormatted))
            },
        ]
        answers = prompt(questions)
        if not answers:
            return

        # Get the chosen list
        chosenList = list(filter(lambda lst: lst['name'] == answers['list'], listsFormatted))
        if len(chosenList) <= 0:
            click.echo("Invalid choice of list")
            return
        chosenList = chosenList[0]

        # Show tasks in the chosen list
        click.echo(chosenList['name'])
        for task in chosenList['tasks']:
            if task['done'] == True:
                click.echo("[✔] " + task['task'])
            else:
                click.echo("[ ] " + task['task'])

  • First, we get all the todo lists for this user
  • If the flag -a is specified, then we iterate over each list and print the list name and tasks along with the checkmark
  • If the flag -a is not specified, then we prompt the user to select a list, then we iterate over the tasks for that selected list and print the tasks with the checkmark.

Now try it out! It should look like this:

❯ python cli.py ls -a

Web Repo
[ ] Update favicon.ico
[ ] Add our logo to the footer
[ ] Add a GIF that shows how our product works

Morning Routine
[ ] Drink coffee
[ ] Grab yogurt
[ ] Drink fish-oil

Obviously, none of our tasks are done since we haven't made the function to mark a task as done. Let' do that next.

Check and Un-check a Task

We'll use PyInquirer's powerful checklist type to allow the user to check and un-check the tasks. Our API to update a task looks like this:

### Update task set done = true or false by id
PUT http://localhost:1234/todo/update/done/274822869038400008
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "done": true
}

Let's add a function called toggle to our cli.py.

# cli.py
@main.command()
def toggle():
    """Update tasks in a list"""
    # Get access token from file
    access_token = tokenhandler.get_token_from_file(token_file_name, api_key)["access_token"]

    # Call API to list all tasks
    url = "https://cottertodolist.herokuapp.com/list"
    headers = {'Authorization': 'Bearer ' + access_token}
    response = requests.get(url, headers=headers)
    if response.status_code != 200:
        response.raise_for_status()
    listsFormatted = response.json()

    # Show a prompt to choose a list
    questions = [
        {
            'type': 'list',
            'name': 'list',
            'message': 'Which list do you want to update?',
            'choices': list(map(lambda lst: lst['name'], listsFormatted))
        },
    ]
    answers = prompt(questions)
    if not answers:
        return

    # Get the chosen list
    chosenList = list(filter(lambda lst: lst['name'] == answers['list'], listsFormatted))
    if len(chosenList) <= 0:
        click.echo("Invalid choice of list")
        return
    chosenList = chosenList[0]

    # Show an interactive checklist for the tasks
    questions = [
        {
            'type': 'checkbox',
            'message': chosenList['name'],
            'name': chosenList['name'],
            'choices': list(map(lambda task: {'name': task['task'], 'checked': task["done"]}, chosenList['tasks'])),
        }
    ]
    answers = prompt(questions)
    if not answers:
        return

    # Call our Update API for each task in the list
    # set `done` as True or False based on answers
    for task in chosenList['tasks']:
        url = "https://cottertodolist.herokuapp.com/todo/update/done/" + task['id']
        headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + access_token}
        data = {
            "done": task['task'] in answers[chosenList['name']]
        }
        response = requests.put(url, json=data, headers=headers)
        if response.status_code != 200:
            response.raise_for_status()

    click.echo(answers)
  • First, we call the list API to get all the lists for the user and prompt them to select a list.
  • Then using PyInquirer with type: checklist we can show them a checklist for the tasks in the list, and set {'checked': True} for tasks that are already done.
  • The user can use <space> to select or deselect a task, and press enter to update the task.

It looks like this:

python cli.py toggle

Let's see our complete todo list again:

❯ python cli.py ls -a

Web Repo
[ ] Update favicon.ico
[ ] Add our logo to the footer
[ ] Add a GIF that shows how our product works

Morning Routine
[✔] Drink coffee
[✔] Grab yogurt
[ ] Drink fish-oil

Awesome! Our Todo-List CLI is done!

The final result should look like this:

Python Todo List CLI<br>
This post is written by the team at Cotter – we are building lightweight, fast, and passwordless login solutions for websites, mobile apps, and now CLIs too! If you're building a website, app, or CLI, we have all the tools you need to get a login flow set up in minutes.


What's Next?

It's pretty lame if your user has to always call python cli.py create, we want to change it to todo create instead. Follow this article to find out how to do that.


Questions & Feedback

Come and talk to the founders of Cotter and other developers who are using Cotter on Cotter's Slack Channel.

Ready to use Cotter?

If you enjoyed this tutorial and want to integrate Cotter into your website or app, you can create a free account and check out our documentation.

If you need help, ping us on our Slack channel or email us at team@cotter.app.

Posted on by:

putrikarunia profile

Putri Karunia

@putrikarunia

Co-founder of Cotter.app, web dev & design enthusiast.

Cotter

Cotter is a passwordless authentication service that helps developers add a secure and user-friendly login to their website or app in just a few lines of code.

Discussion

pic
Editor guide
 

This doesn't work for me. I am constantly getting a 401 error from cottertodolist after successful login. It looks like problem is with this part of code:


resp = requests.post(url, json=data, headers=headers)

if resp.status_code != 200:
    resp.raise_for_status()

The error I get is:

requests.exceptions.HTTPError: 401 Client Error: UNAUTHORIZED for url: https://cottertodolist.herokuapp.com/login_register

Any help is appreciated.

 

Hi Stanko, thanks for catching this! I believe this issue is resolved in Github.

 

Sure it is, I created the issue :)

Thanks for the solution.