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.
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:
- CLI functionalities and prompts: Click, and PyInquirer
- Authentication: Cotter
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.
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.
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:
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:
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.
- If you want to make your own REST API in Flask, check out the source code of the Todo List 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. 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.
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.
Top comments (3)
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:
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.