How to Make an Interactive Todo List CLI using Python with an Easy Login Mechanism
Tags: python, tutorial, security
Published: 2020-08-27 18:48:01

*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](https://codeburst.io/building-beautiful-command-line-interfaces-with-python-26c7e1bb54df).

We're going to use some of them to make a nice and interactive todo list CLI.
![Python Todo List CLI
](https://dev-to-uploads.s3.amazonaws.com/i/gkxlp7lcpoptfi38ochu.gif)

# 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](https://github.com/cotter-code/flask-todolist-api).

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

- CLI functionalities and prompts: [Click](https://click.palletsprojects.com/en/7.x/), and [PyInquirer](https://github.com/CITGuru/PyInquirer)
- Authentication: [Cotter](https://github.com/cotterapp/python-sdk)

**Get the full code for this tutorial in [Cotter's Github](https://github.com/cotter-code/guide-python-todolist-cli).**

## Let's start with a super simple CLI
In your project directory, let's start by making a [virtualenv](https://stackabuse.com/python-virtual-environments-explained/):

```bash
python -m venv venv/
```
Then activate the virtual environment with the `source` command:
```bash
source venv/bin/activate
```
Now let's install `click` to make our simple cli:
```bash
pip install click
```
Then make a file for our CLI – let's call it `cli.py` and we'll make a simple `greet` command.

```python
# 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:
```bash
❯ python cli.py greet Putri
Hello, Putri
```

## Now let's build our Todo List CLI.
Let's check our [Todo List API documentation](https://github.com/cotter-code/flask-todolist-api/blob/master/api.http). An example request to show all lists looks like this:
```http
### 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:
```http
### 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:
```bash
pip install cotter
```
Then, let's update our `cli.py` to add a login function:
```python
# 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](https://dev.cotter.app/).

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`](https://github.com/cotterapp/python-sdk/blob/master/example/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:

```bash
❯ 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](https://blog.cotter.app/content/images/2020/08/image-9.png)

Once you're done logging-in, you should see the following response in your terminal:
```json
{
  "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:
```bash
pip install requests
```

Update our `login` function inside `cli.py` to the following:
```python
# 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:

```python
# 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:
```json
### 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

```python
# 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:
```bash
❯ 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:
```json
### 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](https://github.com/CITGuru/PyInquirer) to make a nice prompt with multiple options. First, install PyInquirer:
```bash
pip install PyInquirer
```
Then add a function called `add` in our `cli.py`.
```python
# 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](https://blog.cotter.app/content/images/2020/08/AddTask-1.png)

### 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:
```json
### 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.

```python
# 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:
```bash
❯ 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:
```json
### 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`.
```python
# 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](https://blog.cotter.app/content/images/2020/08/image-14.png)

Let's see our complete todo list again:

```bash
❯ 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
](https://dev-to-uploads.s3.amazonaws.com/i/gkxlp7lcpoptfi38ochu.gif)
This post is written by the team at [Cotter](https://www.cotter.app/) – 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](https://dbader.org/blog/how-to-make-command-line-commands-with-python) to find out how to do that.

- If you want to make your own REST API in Flask, check out [the source code of the Todo List API](https://github.com/cotter-code/flask-todolist-api) that we just used to see how to protect your API routes with an `access_token`.
Want to make a front-end for our Todo list so it's accessible from both the terminal and the web? Check out our tutorials on [implementing the same Cotter Login to your React front end](https://docs.cotter.app/quickstart-guides/react-magic-link-with-email-and-phone). This ensures that your users can log in and see the same list throughout both platforms.

---

### Questions & Feedback
Come and talk to the founders of Cotter and other developers who are using Cotter on [Cotter's Slack Channel](https://join.slack.com/t/askcotter/shared_invite/zt-dxzf311g-5Mp3~odZNB2DwYaxIJ1dJA).

### 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](https://dev.cotter.app/) and [check out our documentation](https://docs.cotter.app/).

If you need help, ping us on our [Slack channel](https://join.slack.com/t/askcotter/shared_invite/zt-dxzf311g-5Mp3~odZNB2DwYaxIJ1dJA) or email us at team@cotter.app.