4
\$\begingroup\$

I am a beginner in Python and I am yet to move on from the basics to things like OOP.I wrote all of this code (Python 3) in an hour or so for a simple countdown timer.Although some parts of it are seemingly excessive,is the core of the code pythonic enough or are there better ways of structuring it?I would also like to know a better way for writing comments and variables effectively.More specifically I think the IOTimer() function could be cleaner.

#modules
import datetime
import time
#TIMER
#Enter Preferences


supported_formats = ["hrs","min","sec"]

print("\n")

def get_input():
    global unit_input
    unit_input = input("Enter the format in which you want to time yourself:{}:".format(supported_formats))
    unit_input.lower()

#Check if the format entered is valid.
get_input()

if unit_input in supported_formats:
    pass
else:
    print("Invalid Input.Please re-enter you desired format.\nThe input is case insenseitive")
    get_input()

def countdown_timer(x):
    while x >= 0 :
        x -= 1
        print("{} remaining".format(str(datetime.timedelta(seconds=x))))
        print("\n")
        time.sleep(1)


#Check for input and assign variable to determine the total amount of seconds
def IOTimer():
    while True:
        try:
            if unit_input == "hrs":
                hours  = int(input("Enter the number of hours: "))
                print("\n")
                minutes = int(input("Enter the number of minutes: "))
                print("\n")
                seconds = int(input("Enter the number of seconds: "))
                print("\n")
                break
            elif unit_input == "min":
                hours = 0
                minutes = int(input("Enter the number of minutes: "))
                print("\n")
                seconds = int(input("Enter the number of seconds: "))
                print("\n")
                break
            elif unit_input == "sec":
                hours = 0
                minutes = 0
                seconds = int(input("Enter the number of seconds: "))
                print("\n")
                break
        except:
            print("Invalid Input.Re-enter the values")
            print("\n")
            IOTimer()
    hours_in_sec = hours*3600
    minutes_in_sec = minutes*60
    total_seconds = hours_in_sec + minutes_in_sec + seconds
    print("Starting countdown for {}".format(str(datetime.timedelta(seconds=total_seconds))))
    print("\n")
    time.sleep(2)
    countdown_timer(total_seconds)

IOTimer()
\$\endgroup\$
5
\$\begingroup\$

Before entering into the meat of the answer, let's talk about time accuracy for a bit.

For starter, time.sleep is not extremely accurate as it can be (a little bit) longer than the requested sleeping time. And even if it was, you’re performing work inbetween the repeated sleep calls, which take time and add up. So each cycle in the while loop of countdown_timer take more than a single second. Let's evaluate how much by using the following file:

import time
import datetime
import timeit


def countdown_timer(x):
    while x >= 0 :
        x -= 1
        print("{} remaining".format(str(datetime.timedelta(seconds=x))))
        print("\n")
        time.sleep(1)


if __name__ == '__main__':
    print(timeit.timeit(lambda:countdown_timer(120), number=1))

And the output is:

0:01:59 remaining


0:01:58 remaining

[Snip for readability]

0:00:01 remaining


0:00:00 remaining


-1 day, 23:59:59 remaining


121.14761522800109

Wow… That remaining time at the end… That's unexpected. Thing is that the way your while loop is setup, you are performing x + 1 iterations; which explains the 121 seconds your countdown take as well as that last remaining time. Just change the condition to while x > 0: and you’re good. But do you see that your counter drift of about 150ms over the course of two minutes? That will be 1 second and a half after 20 minutes, between 4 and 5 seconds on a 1 hour timer and several minutes for a 1 day timer…

You need to account for that drift. And the easiest way to do it is to maintain an ideal time to sleep up to which is 1 second in the future, and then compute the difference between the current time and this ideal time to try and sleep this exact amount of time. Luckily, this difference is easily computed using timedelta.total_seconds:

import time
import datetime
import timeit


def countdown_timer(x, now=datetime.datetime.now):
    target = now()
    one_second_later = datetime.timedelta(seconds=1)
    for remaining in range(x, 0, -1):
        target += one_second_later
        print(datetime.timedelta(seconds=remaining), 'remaining', end='\r')
        time.sleep((target - now()).total_seconds())
    print('\nTIMER ended')


if __name__ == '__main__':
    print(timeit.timeit(lambda:countdown_timer(120), number=1))

And the timing is way better:

0:00:00 remaining
TIMER ended
120.00124583899742

Just a single millisecond of drift. In fact, this drift is always compensated for and never accumulate. A run for 1200 seconds outputs:

0:00:00 remaining
TIMER ended
1200.0010585399978

Now for the rest of the code. Did you note how the calling code was under an if __name__ == '__main__': clause? You should get into this habit as it allows to more easily import and test the code you are developping. This will also hopefully force you to gather your code into functions and to make better usage of parameters and return values.

A coarse rewrite could look like:

import time
import datetime


def countdown_timer(x, now=datetime.datetime.now):
    target = now()
    one_second_later = datetime.timedelta(seconds=1)
    for remaining in range(x, 0, -1):
        target += one_second_later
        print(datetime.timedelta(seconds=remaining), 'remaining', end='\r')
        time.sleep((target - now()).total_seconds())
    print('\nTIMER ended')


def get_input_format(supported_formats=('hrs', 'min', 'sec')):
    while True:
        units = input('Enter the format in which you want to time yourself {}: '.format(supported_formats))
        if units in supported_formats:
            return units
        print('Invalid input. Please re-enter your desired format')
        print('The input is case sensitive')


def IOTimer(unit_input):
    while True:
        try:
            if unit_input == "hrs":
                hours = int(input("Enter the number of hours: "))
                print("\n")
                minutes = int(input("Enter the number of minutes: "))
                print("\n")
                seconds = int(input("Enter the number of seconds: "))
                print("\n")
                break
            elif unit_input == "min":
                hours = 0
                minutes = int(input("Enter the number of minutes: "))
                print("\n")
                seconds = int(input("Enter the number of seconds: "))
                print("\n")
                break
            elif unit_input == "sec":
                hours = 0
                minutes = 0
                seconds = int(input("Enter the number of seconds: "))
                print("\n")
                break
        except:
            print("Invalid Input. Re-enter the values")
            print("\n")
    hours_in_sec = hours*3600
    minutes_in_sec = minutes*60
    total_seconds = hours_in_sec + minutes_in_sec + seconds
    print("Starting countdown for {}".format(str(datetime.timedelta(seconds=total_seconds))))
    print("\n")
    time.sleep(2)
    countdown_timer(total_seconds)


if __name__ == '__main__':
    IOTimer(get_input_format())

But there is still improvement for the IOTimer function:

  • the name looks like it is a class as per PEP8, the official Python style guide, function names should be lower_snake_case;
  • I already removed the recursive call as it is already handled by the while True: loop;
  • there is a lot of repetitions in the various if statements;
  • you should not use a bare except statement, always specify the exceptions you’re expecting, otherwise it may create hard-to-diagnose bugs;
  • I hardly see a valid reason to sleep 2 seconds before starting the timer.

Revised version look like:

import time
import datetime


def countdown_timer(x, now=datetime.datetime.now):
    target = now()
    one_second_later = datetime.timedelta(seconds=1)
    for remaining in range(x, 0, -1):
        target += one_second_later
        print(datetime.timedelta(seconds=remaining), 'remaining', end='\r')
        time.sleep((target - now()).total_seconds())
    print('\nTIMER ended')


def get_input_format(supported_formats=('hrs', 'min', 'sec')):
    while True:
        units = input('Enter the format in which you want to time yourself {}: '.format(supported_formats))
        if units in supported_formats:
            return units
        print('Invalid input. Please re-enter your desired format')
        print('The input is case sensitive')


def main(unit_input):
    hours = minutes = seconds = 0
    while True:
        try:
            if unit_input == 'hrs':
                hours = int(input('Enter the number of hours: '))
            if unit_input != 'sec':
                minutes = int(input('Enter the number of minutes: '))
            seconds = int(input('Enter the number of seconds: '))
        except ValueError:
            print('Invalid Input. Re-enter the values')
        else:
            break
    delay = datetime.timedelta(hours=hours, minutes=minutes, seconds=seconds)
    print('Starting countdown for {}'.format(delay))
    countdown_timer(int(delay.total_seconds()))


if __name__ == '__main__':
    main(get_input_format())

But the whole point of these two extra functions is to only gather parameters for the script from the user. Let specialized modules do the work for you. Let me introduce you to argparse:

import time
import datetime
import argparse


def countdown_timer(x, now=datetime.datetime.now):
    target = now()
    one_second_later = datetime.timedelta(seconds=1)
    for remaining in range(x, 0, -1):
        target += one_second_later
        print(datetime.timedelta(seconds=remaining), 'remaining', end='\r')
        time.sleep((target - now()).total_seconds())
    print('\nTIMER ended')


def command_line_parser():
    parser = argparse.ArgumentParser(description='Simple countdown timer', conflict_handler='resolve')
    parser.add_argument('seconds', type=int, help='amount of seconds to wait for')
    parser.add_argument('-m', '--minutes', '--min', type=int, help='additional amount of minutes to wait for')
    parser.add_argument('-h', '--hours', type=int, help='additional amount of hours to wait for')
    return parser


if __name__ == '__main__':
    args = command_line_parser().parse_args()
    delay = datetime.timedelta(**vars(args))
    print('Starting countdown for', delay)
    countdown_timer(int(delay.total_seconds()))

Usage being:

$ python simple_timer.py -h 1 -m 23 45
\$\endgroup\$
  • \$\begingroup\$ Excellent answer! It may be aesthetically better to do print(datetime.timedelta(seconds=remaining-1), 'remaining', end='\r'). That way our timer will end with the expected 0:00:00 \$\endgroup\$ – user3418052 Oct 11 '18 at 18:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.