Jhonatan da Silva

Jhonatan da Silva

How I made - Pomodoro with Python

The idea here is to implement a Pomodoro timer, if you don't know the concept, you work for 25min minutes, and then take a break of 5min, work another 25 and take another break, in the 4th break, you can rest for more time, something like 15min.

The script you just see stores the user preferences, like having a 30min pomodoro default time instead of 25min, as well as the short and break time. We'll also store how much pomodoros he has done, in order to set a longer time break every 4th break.

Assumptions

Some assumptions before we start, I believe that you have python install and know how to install python libraries. It would be good if you are on Linux as well, but it's not a requirement.

Analysis of the problem

Now that we've lined up the assumptions. The first thing we should do when approaching this problem is to think:

  1. Do I want to persist the data?

If so, how to do it?

In my case, I thought about:

  1. Using a simple file
  2. SQLITE
  3. Redis

As the idea here is to approach more as a beginner friendly tutorial, Redis was the way I implemented, as you'll see later on, is something like manipulating a dictionary.

Now that the data side of it is thought thru, we need to think about how to get the user input, something like getting how many minutes does he want his default pomodoro time to be, or how long the break should be.

To solve this problem, or whenever I want to make a script that will act as a CLI, I use library from python called typer.

Requirements

Ok, now we have defined how we'll solve the problem. The only complication you might encounter is to install redis itself.

sudo apt-get install redis-server
redis-cli

If you are on Linux, you can install with apt-get install redis-server, then you can test by typing redis-cli, if you see something like this.

127.0.0.1:6379>

You also need to have python installed.

Starting out

As stated, we'll be using redis and typer, so we need to import both libraries

#!/usr/bin/python3
import redis
import typer

The most basic thing you could do now to test typer is to create an app, create a simple function with the decorator @app.command(), and run it.

app = typer.Typer()

@app.command()
def test():
  typer.echo("Live!")

if __name__ == "__main__":
  app()

So, if you ran this, it'll print Live!

python pomodoro.py

While you have only one function, test, typer will not ask for you to type something like

python pomodoro.py test

But if we create another function

@app.command()
def typer_hello():
  typer.echo("Hello")

Typer will show you all the functions the user can execute, so you will need to execute like this

python pomodoro.py typer_hello

Ok, now that the basics are covered, what is next?

Redis

So now we need to get the user input, like

  1. Default pomodoro time
  2. Short break time
  3. Long break time

To store things in redis, first we need to instantiate the connection, then we can add something like how many times the user has completed the pomodoros with the .set option.

.
.
app = typer.Typer()
db = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db.set("completed_times", 0)
.
.

With this, we can connect to redis. The db=0 means the database, if you want to make another script using redis, and not use the same database as this app, you can change it to 1, 2, 3 and so on.

To set the variables in redis, we need to colect the user input, this is why we are using typer, it makes really easy to interact with the user and make a CLI app.

We can create a new function, init, that the user can execute to configure the app.

@app.command()
def init(
    default_time: int = typer.Option(25, prompt=True),
    short_break: int = typer.Option(5, prompt=True),
    long_break: int = typer.Option(15, prompt=True)
) -> None:
    data = {
        "default_time": default_time,
        "short_break": short_break,
        "long_break": long_break,
    }
    create = typer.confirm(f"Are you sure? {data=}")
    if not create:
        typer.echo("Not creating")
        raise typer.Abort()
    typer.echo("Creating it!")
    set_redis_values(data)

If you execute this right away, it will not work, as we haven't created the set_redis_values yet, but let's break down each line.

To get an input from the user, we can use

typer.Argument... 
typer.Option...

Argument is something that the user should not miss, something like a function to say hello to the user, the argument could be the name. The arguments are the first things passed to typer when you are executing the script. While the options you need to pass -opt

python pomodoro.py arg1 arg2 -opt opt1 -opt opt2 

As we could possible pass this script to another person just to use it, we could also use the prompt=True option, so that the user can just run the script and it will ask for the values.

...
  (
    default_time: int = typer.Option(25, prompt=True),
    short_break: int = typer.Option(5, prompt=True),
    long_break: int = typer.Option(15, prompt=True)
  ):
  ...

After asking for the data, we just create a simple dictionary with the values

...
  data = {
      "default_time": default_time,
      "short_break": short_break,
      "long_break": long_break,
  }
  ...

Then we use the typer.confirm to ask if the typed information is correct.

And finally, we pass this data to redis

Set Redis Values

Optional: - Gif in Blender showing the packaging of variables

We can now implement the function set_redis_values, this will receive the data, and also it will create a variable of how many times the user has completed.

(improve on this explication after creating the gifs)

def set_redis_values(data):
    [db.set(key, data[key]) for key in data.keys()]

Testing out

Is always good to test things as we go. As we finished the implementation of the init function, just to visualize the whole workflow, we can just create a placeholder for our next function.

.
.
@app.command()
def start():
  pass
.
.

with this, you can run

python pomodoro.py init

If you have not created the start, you probably will get an error, it's because when you have only one app.command(), it will execute without asking the name of the function.

This will ask you for the input, but ok, how do we test if everything went ok?

I personally like to use ipdb, or pdb. Pdb comes with python, ipdb you can install with pip.

So, after installing, you can use

ipdb.set_trace()

Where you want the code to stop so that you can see the value from the variables.

So we can change the function to

def set_redis_values(data):
  ipdb.set_trace() 
  [db.set(key, data[key]) for key in data.keys()]

In my case, I have a snippet that when I write ipdb and press tab, it changes to, this works as well, you could see how to do this with your code editor.

__import__('ipdb').set_trace()

When you run the script again, you'll see that it stops.

You can then look at your variables, we can get the values of the redis database with the .get method instead of .set

db.get("default_time")

In this way, you can experiment with changing the values in init, and looking if redis has setted the value as you expect, this is a good way of debugging, in case you ask for 1min short break and the script is giving you 5min, you can stop the script and look what is doing.

Pomodoro logic

Handling the data

Ok, we are coming to the end. The first thing we should do, is to write a function to retreive the data from redis

def get_data() -> dict:
    data = {
        "fourth_time":False,
        "break_time":0,
    }
    data["break_time"] = db.incrby("short_break", 0)
    data["default_time"] = db.incrby("default_time", 0)

    completed_times = db.incry("complted_times", 0)
    if completed_times and completed_times % 4 == 0:
        data["break_time"] = db.incrby("long_break",0)
        data["fourth_time"] = True
    
    return data

As redis will return a string, as we defined our connection with the encoding option, if we used

data["default_time"] = db.get("completed_times")

We would need to cast the values to int or float before moving on, this for all values, an approach to getting the values is to use the

data["default_time"] = db.incrby("completed_times", 0)

This would increase 0 in the variable and return the casted value. You could also use

data["completed_times"] = int(db.incrby("complted_times"))

After that, we check if it's the fourth pomodoro. If so, we set the break time as the long break. If not, we set as the short break. As the 4th pomodoro you should take a long break.

That's pretty much it for the data gathering, now that we handled the logic, it will be much simpler to write a function to wait for the time.

Actual Pomodoro logic

Now for the final logic, the first thing we do is to get the data, then we extract the values from the dict, you could also return both values in the function and no store in a dict, but this is more robust in case you want to add more variables in the future.

@app.command()
def start():
    data = get_data()
    break_time = data["completed_times"]
    default_time = data["default_time"]

With the default_time we can actually use the time.sleep to wait for the time the user has passed. It's an important note here that if the user set a float or something else in the init function, the code will break, you could ensure that the variables are int in the init function by validating the user input and then saving into redis.

typer.echo("[*] Started Pomodoro")
    pomodoro_chunks = [1 for _ in range(default_time)]
    for t in pomodoro_chunks:
        time.sleep(60*t)
        typer.echo("[-] 1min passed")

After this, we increase completed_times by one and ask the user if he wants to start the rest

typer.echo("[*] Completed Pomodoro")
    db.incrby("completed_times", 1)
    start_rest = typer.confirm("Start rest?")
    if not start_rest:
        typer.echo("Exiting")
        typer.Abort()

If so, we use the same logic to sleep with the break_time, to avoid code duplication, we can refactor this into a function

def incremental_sleep(sleep_time: int) -> None:
    pomodoro_chunks = [1 for _ in range(sleep_time)]
    for t in pomodoro_chunks:
        time.sleep(60*t)
        typer.echo("[-] 1min passed")

So that our function ends like this

@app.command()
def start():
    data = get_data()
    break_time = data["completed_times"]
    default_time = data["default_time"]

    typer.echo("[*] Started Pomodoro")
    incremental_sleep(default_time)

    typer.echo("[*] Completed Pomodoro")
    db.incrby("completed_times", 1)
    start_rest = typer.confirm("Start rest?")
    if not start_rest:
        typer.echo("Exiting")
        typer.Abort()

    incremental_sleep(break_time)
    typer.echo("[*] Session finished!")

What's next?

  • Add users
  • Play sound effects
  • Give the user the ability to add tags
  • Show statistics
  • Reset the data
  • Run redis on docker

Another useful thing when developing projects like this, even more if you want to share your script with others, is to use Docker to ensure the script will run in most places, with the same behavior, most of the times at least, but this would be a talk for another time.

I have a box set of tutorials, if you are interested in knowing how to user Docker to instantiate a Redis instance and test our script with a dockerized redis, this one of many things I teach in this box set of tutorials

I've been working with software development for the past 5 years, and I pretend to fill this box of tutorials with content until the end of the year, so if you buy now you'll get all the content for "free", kind like an early access.

I'll see you around.

Final script

#!/usr/bin/python3
import redis
import typer
import time 

app = typer.Typer()
db = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db.set("completed_times", 0)

def set_redis_values(data):
    [db.set(key, data[key]) for key in data.keys()]

@app.command()
def init(
    default_time: int = typer.Option(25, prompt=True),
    short_break: int = typer.Option(5, prompt=True),
    long_break: int = typer.Option(15, prompt=True)
) -> None:
    data = {
        "default_time": default_time,
        "short_break": short_break,
        "long_break": long_break,
    }
    create = typer.confirm(f"Are you sure? {data=}")
    if not create:
        typer.echo("Not creating")
        raise typer.Abort()
    typer.echo("Creating it!")
    set_redis_values(data)

def get_data() -> dict:
    data = {
        "fourth_time":False,
        "break_time":0,
    }
    data["break_time"] = db.incrby("short_break", 0)
    data["default_time"] = db.incrby("default_time", 0)

    completed_times = db.incrby("completed_times", 0)
    if completed_times and completed_times % 4 == 0:
        data["break_time"] = db.incrby("long_break",0)
        data["fourth_time"] = True
    
    return data

def incremental_sleep(sleep_time: int) -> None:
    pomodoro_chunks = [1 for _ in range(sleep_time)]
    for t in pomodoro_chunks:
        time.sleep(60*t)
        typer.echo("[-] 1min passed")


@app.command()
def start():
    data = get_data()
    break_time = data["completed_times"]
    default_time = data["default_time"]

    typer.echo("[*] Started Pomodoro")
    incremental_sleep(default_time)

    typer.echo("[*] Completed Pomodoro")
    db.incrby("completed_times", 1)
    start_rest = typer.confirm("Start rest?")
    if not start_rest:
        typer.echo("Exiting")
        typer.Abort()

    incremental_sleep(break_time)
    typer.echo("[*] Session finished!")

if __name__ == "__main__":
    app()

References

Backlinks: