#!/usr/bin/env python3 # Copyright 2024 vgm+dev@devys.org # SPDX-License-Identifier: MIT ''' Simple and fancy sleep for console. Time can be precised as [DDd][HHh][MMm][SS[s]]. Number of digits for days, hours, minutes or seconds are not restricted. Every type (days, hours, minutes, seconds) are optional, but at least one must be given. If no suffix is given, it means seconds. Spaces between group of types can be given but TIME as a whole must be a single argument. Time can also be given as HH:MM or HH:MM:SS. An optional command to run at the end of the time can be given. Usage: fancy-sleep [options] TIME [--] [COMMAND...] fancy-sleep -h|--help Options: -h, --help Display this help message --target Enable the display of the target time ''' import contextlib import datetime import os import re import sys import termios import time import docopt # to avoid accidental trailing spaces removal, use another character and # replace. NUMBERS = [i.replace('.', ' ') for i in ( """\ ██████ ██ ██ ██ ██ ██ ██ ██████ """, """\ ██ ██ ██ ██ ██ """, """\ ██████ ██ ██████ ██.... ██████ """, """\ ██████ ██ ██████ ██ ██████ """, """\ ██ ██ ██ ██ ██████ ██ ██ """, """\ ██████ ██.... ██████ ██ ██████ """, """\ ██████ ██.... ██████ ██ ██ ██████ """, """\ ██████ ██ ██ ██ ██ """, """\ ██████ ██ ██ ██████ ██ ██ ██████ """, """\ ██████ ██ ██ ██████ ██ ██████ """, """\ . ██. . ██. . """, )] NUMBERS_LINES = 5 def print_translated(string): 'print 09:30 style time to big:numbers' translated = [] for character in string: index = 0 if character >= '0' and character <= '9': index = ord(character) - ord('0') elif character == ':': index = 10 translated.append(NUMBERS[index]) display_lines = ['', '', '', '', '', ''] for index in range(5): for number in translated: display_lines[index] += number.splitlines()[index] print('\n'.join(display_lines), end='') def parse_time(times, time_pattern=re.compile(r'(\d+)([dhms]?)')): 'return a cumulative datetime.timedelta from time string (times)' default = datetime.datetime.strptime('0', '%S') timedelta = datetime.timedelta() try: timedelta += datetime.datetime.strptime(times, '%H:%M:%S') - default return timedelta except ValueError: try: timedelta += datetime.datetime.strptime(times, '%H:%M') - default return timedelta except ValueError: pass for match in time_pattern.finditer(times): if match.group(2) == 'd': timedelta += datetime.timedelta(days=float(match.group(1))) elif match.group(2) == 'h': timedelta += datetime.timedelta(hours=float(match.group(1))) elif match.group(2) == 'm': timedelta += datetime.timedelta(minutes=float(match.group(1))) elif match.group(2) == 's' or match.group(2) == '': timedelta += datetime.timedelta(seconds=float(match.group(1))) return timedelta def test_parse_time(): 'test parse_time function (callable with pytest-3)' assert parse_time('11') \ == datetime.timedelta(seconds=11) assert parse_time('11s') \ == datetime.timedelta(seconds=11) assert parse_time('11s') == parse_time('11') assert parse_time('11h 47m') \ == datetime.timedelta(hours=11, minutes=47) assert parse_time('11h 47m') == parse_time('0d11h47m0s') assert parse_time('11h 47') \ == datetime.timedelta(hours=11, seconds=47) assert parse_time('11h 47') == parse_time('11h47s') assert parse_time('11h 47m 09') \ == datetime.timedelta(hours=11, minutes=47, seconds=9) assert parse_time('11h 47m 09') == parse_time('11h47m9s') assert parse_time('09:53') \ == datetime.timedelta(hours=9, minutes=53) assert parse_time('09:53') == parse_time('09h 53m') assert parse_time('8:2') \ == datetime.timedelta(hours=8, minutes=2) assert parse_time('8:2') == parse_time('08:02') assert parse_time('8d 6s') \ == datetime.timedelta(days=8, seconds=6) assert parse_time('8d 6s') == parse_time('8d6s') def get_hms_from_secs(seconds): hours = int(seconds / 3600) minutes = int(seconds / 60) % 60 seconds = int(seconds) % 60 return hours, minutes, seconds def get_hms_string_from_secs(seconds): hours, minutes, seconds = get_hms_from_secs(seconds) time_string = '%02d:%02d:%02d' % (hours, minutes, seconds) return time_string def fancy_sleep_display(target_time): original_termios_attr = termios.tcgetattr(sys.stdout) noecho_termios_attr = original_termios_attr[:] noecho_termios_attr[3] &= ~termios.ECHO termios.tcsetattr(sys.stdout, termios.TCSANOW, noecho_termios_attr) try: while True: delta = target_time - time.time() if delta <= 0: break #print('\x1b[s', end='') # save cursor print_translated(get_hms_string_from_secs(delta)) print(f'\x1b[{NUMBERS_LINES}A', end='') # go up 5 lines up #print('\x1b[u', end='') # restore cursor time.sleep(1) finally: print('\x1b[5B', end='') # go up 5 lines down termios.tcsetattr(sys.stdout, termios.TCSANOW, original_termios_attr) def main(): 'function called only when script invoked directly on command line' args = docopt.docopt(__doc__) isatty = sys.stdout.isatty() curtime = datetime.datetime.now().replace(microsecond=0) timedelta = parse_time(args['TIME']) target_time = curtime + timedelta if timedelta == datetime.timedelta(0): print('ERROR: TIME is unparsable or equals to 0', file=sys.stderr) sys.exit(1) if args['--target']: print('Run until:', target_time.strftime('%F %T')) sys.stdout.flush() interrupted = False try: if not isatty: time.sleep(target_time.timestamp() - time.time()) else: fancy_sleep_display(int(target_time.timestamp())) except KeyboardInterrupt: interrupted = True if isatty: print('slept for', get_hms_string_from_secs( time.time() - int(curtime.timestamp()))) if interrupted: sys.exit(130) if args['COMMAND']: os.execvp(args['COMMAND'][0], args['COMMAND']) if __name__ == '__main__': main()