#! /usr/bin/env python # McAuthor: CarpNet (thanks to a paradigm shifting idea by Bikeman) # TODO 1: some crazy AI heuristics stuff, see The Terminator and Skynet for ideas. # TODO 2: Add time travel capability (note: mind the butterfly effect and for god's sake don't # sleep with your relatives in the past we don't want anyone ending up as their own Grandad or # Grandma. Just because your Gran looked fetching in that photo of her on the beach in the 1950's # doesn't mean you should hit on her and end up creating a paradox that could potentially end the # universe, fucking hell guys this is just for telling percentages of time!) # TODO 3: Add recognised time periods to be accepted (e.g. Friday, Tomorrow, Next Week, Next Year, New Years, Christmas) # TODO 4: Display percentage as a chart instead # TODO 5: Extend chart to be different types (bar, pie, raised-arm-man etc.) # Raised arm man chart: \o/ = 1 hour, \ = 20 minutes, \o = 40 minutes etc. # TODO 6: Web trawling important date finder # TODO 7: Start and end dates to be accepted # TODO 8: Birthdays # CHANGE LOG: # 3.21: - Bug fix for option --NICK. Changed option back to --nick # - Change log added # 3.22: - Fix bug fix to fix the fix that was fixed in the last bug fix # 3.23: - Assumes end time if given one argument, start and end if given two. # - Restructuring of code in parseArgs function # - Added "halloween" as an IMPORTANT TEMPORAL EVENT # 3.3: # - Added 'mode' as second argument, choices are 'user' and 'admin': # - Users are allowed to check times and dates # - Admins can create and remove custom events # - Allows creation and removal of custom temporal events (stored in perc.cfg) # - Maintains a list of admins allowed to perform admin actions # 3.4: # - Ability to add new users added # 3.4.1: # - Can now clone (-c/--clone) existing users to create new users (also allows other options in addition such as -s -f to further customise a new user) import copy import decimal import json import optparse import random import re import string import sys from datetime import datetime, date, timedelta, time PROG = "perc" VERSION = "3.4.1" CONFIG_FILE = "perc.cfg" NOW = datetime.now() MODES = ["user", "admin"] USER = None DEFAULT_USER = None CONFIG = {} CONFIG_IS_DIRTY = False EVENT_DATETIME_FORMAT = "%d-%m-%Y %H:%M" MONDAY = 0 TUESDAY = 1 WEDNESDAY = 2 THURSDAY = 3 FRIDAY = 4 SATURDAY = 5 SUNDAY = 6 WEEK_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] WEEK_LENGTH = 7 WEEKEND = [ SATURDAY, SUNDAY ] WORK_WEEK = [ MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY ] IMPORTANT_TEMPORAL_EVENTS = ["LATER", "tomorrow", "friday", "new year", "newyear", "christmas", "xmas", "weekend", "midday", "noon", "lunch", "easter", "halloween"] OUTPUT_TYPES = ["perc", "bar", "ram", "pint"] VERB_FORMS = ["is", "is not", "should be", "could be", "would be", "ought to be", "might be", "may become"] def clamp(value, min_value, max_value): if value < min_value: return min_value elif value > max_value: return max_value return value def float_to_decimal(f): # http://docs.python.org/library/decimal.html#decimal-faq "Convert a floating point number to a Decimal with no loss of information" n, d = f.as_integer_ratio() numerator, denominator = decimal.Decimal(n), decimal.Decimal(d) ctx = decimal.Context(prec=60) result = ctx.divide(numerator, denominator) while ctx.flags[decimal.Inexact]: ctx.flags[decimal.Inexact] = False ctx.prec *= 2 result = ctx.divide(numerator, denominator) return result def delta_in_seconds(delta): return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 10**6) / 10**6 def calculate_ratio(start, finish): total = float(delta_in_seconds(finish - start)) done = float(delta_in_seconds(NOW - start)) ratio = 0.0 if total != 0.0: ratio = done / total return ratio def round_to_sigfig(number, sigfig): # http://stackoverflow.com/questions/2663612/nicely-representing-a-floating-point-number-in-python/2663623#2663623 assert(sigfig > 0) try: d = decimal.Decimal(number) except TypeError: d = float_to_decimal(float(number)) sign, digits, exponent = d.as_tuple() if len(digits) < sigfig: digits = list(digits) digits.extend([0] * (sigfig - len(digits))) shift = d.adjusted() result = int(''.join(map(str, digits[:sigfig]))) # Round the result if len(digits) > sigfig and digits[sigfig] >= 5: result += 1 result = list(str(result)) # Rounding can change the length of result # If so, adjust shift shift += len(result) - sigfig # reset len of result to sigfig result = result[:sigfig] if shift >= sigfig-1: # Tack more zeros on the end result += ['0'] * (shift - sigfig + 1) elif 0 <= shift: # Place the decimal point in between digits result.insert(shift + 1, '.') else: # Tack zeros on the front assert(shift < 0) result = ['0.'] + ['0'] * (-shift - 1) + result if sign: result.insert(0, '-') return ''.join(result) def parseTime(timestr): for key, value in CONFIG["FORMAT"]["time"].items(): try: t = datetime.strptime(timestr, value) return NOW.replace(hour=t.hour, minute=t.minute, second=t.second, microsecond=t.microsecond), value except ValueError: pass print "Not a correctly formatted time: '%s'" % timestr sys.exit(1) def isImportantTemporalEvent(event): return event in IMPORTANT_TEMPORAL_EVENTS + CONFIG["CUSTOM_EVENTS"].keys() def isTime(timestr): for key, value in CONFIG["STANDARDS"].items(): m = re.search(value, timestr) if m: return True return False def getDayOfWeek(day): return (NOW - day_of_week() * timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + day * timedelta(days=1) def parseTemporalEvent(event, user): start, finish = None, None if event in CONFIG["CUSTOM_EVENTS"]: start = datetime.strptime(CONFIG["CUSTOM_EVENTS"][event]["start"], EVENT_DATETIME_FORMAT) finish = datetime.strptime(CONFIG["CUSTOM_EVENTS"][event]["finish"], EVENT_DATETIME_FORMAT) format = CONFIG["CUSTOM_EVENTS"][event]["format"] start_formatted = start.strftime(format).format(name=event) finish_formatted = finish.strftime(format).format(name=event) elif event == "LATER": start = NOW - timedelta(minutes=(random.randint(1, 400) + random.randint(1, 400)) / 2) finish = NOW + timedelta(minutes=(random.randint(1, 400) + random.randint(1, 400)) / 2) start_formatted = start.strftime("%H:%M") finish_formatted = finish.strftime("%H:%M") elif event == "tomorrow": start = NOW.replace(hour=0, minute=0, second=0, microsecond=0) finish = NOW.replace(day=NOW.day + 1, hour=0, minute=0, second=0, microsecond=0) start_formatted = WEEK_DAY_NAMES[day_of_week()] finish_formatted = WEEK_DAY_NAMES[day_of_week(date.today() + timedelta(days=1))] elif event in ["christmas", "xmas"]: start = NOW.replace(year=NOW.year - 1, month=12, day=25, hour=0, minute=0, second=0, microsecond=0) finish = NOW.replace(year=NOW.year, month=12, day=25, hour=0, minute=0, second=0, microsecond=0) start_formatted = "%s %d" % (string.capwords(event), start.year) finish_formatted = "%s %d" % (string.capwords(event), finish.year) elif event in ["new year", "newyear"]: start = NOW.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) finish = NOW.replace(year=NOW.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0) start_formatted = str(start.year) finish_formatted = str(finish.year) elif event == "weekend": start = getDayOfWeek(MONDAY) finish = getDayOfWeek(FRIDAY) start = datetime.combine(start.date(), user.start.time()) finish = datetime.combine(finish.date(), user.finish.time()) start_formatted = "Monday" finish_formatted = "Weekend" elif event == "friday": start = getDayOfWeek(MONDAY) finish = getDayOfWeek(FRIDAY) start = datetime.combine(start.date(), user.start.time()) start_formatted = "Monday" finish_formatted = "Friday" elif event in ["midday", "noon"]: start = NOW.replace(hour=0, minute=0, second=0, microsecond=0) finish = NOW.replace(hour=12, minute=0, second=0, microsecond=0) start_formatted = "Midnight" finish_formatted = string.capwords(event) elif event == "halloween": start = NOW.replace(year=NOW.year - 1, month=10, day=31, hour=0, minute=0, second=0, microsecond=0) finish = NOW.replace(year=NOW.year, month=10, day=31, hour=0, minute=0, second=0, microsecond=0) start_formatted = "Halloween %d" % start.year finish_formatted = "Halloween %d" % finish.year elif event == "lunch": if NOW < user.lunch.start: start = user.start finish = user.lunch.start finish_formatted = "Lunch (%s)" % finish.strftime(user.format) else: start = user.lunch.start finish = user.lunch.finish finish_formatted = finish.strftime(user.format) start_formatted = start.strftime(user.format) return start, finish, start_formatted, finish_formatted def readConfig(): global CONFIG with open(CONFIG_FILE, "r") as f: CONFIG = json.load(f) def day_of_week(day=date.today()): return date.weekday(day) def isWeekend(): return day_of_week() in WEEKEND def combination_pairs(first, second): combined = [] for x in first: for y in second: combined.append("%s %s" % (x, y)) combined.append("%s %s" % (y, x)) return combined def parseDatetime(datetimestring, today_relative=False): date_formats = CONFIG["FORMAT"]["date"].values() time_formats = CONFIG["FORMAT"]["time"].values() combined_formats = combination_pairs(date_formats, time_formats) for format in date_formats + time_formats + combined_formats: try: t = datetime.strptime(datetimestring, format) if today_relative: t = datetime.combine(NOW.date(), t.time()) return t, format except ValueError: pass print "Not a recognised date/time '%s'" % datetimestring sys.exit(1) def addEvent(name, start, finish, format="{name} %Y"): if start > finish: print "Event cannot have a start time later than the finish" sys.exit(1) if name in CONFIG["CUSTOM_EVENTS"]: print "Replaced existing event with new info" else: print "Event added: '%s'" % name event = { "start": start.strftime(EVENT_DATETIME_FORMAT), "finish": finish.strftime(EVENT_DATETIME_FORMAT), "format": format } CONFIG["CUSTOM_EVENTS"][name] = event global CONFIG_IS_DIRTY CONFIG_IS_DIRTY = True def removeEvent(name): if name in CONFIG["CUSTOM_EVENTS"]: del CONFIG["CUSTOM_EVENTS"][name] print "Event removed: '%s'" % name global CONFIG_IS_DIRTY CONFIG_IS_DIRTY = True def parseUserArgs(args, user): start, finish, start_formatted, finish_formatted = user.start, user.finish, user.start.strftime(user.format), user.finish.strftime(user.format) if args and isImportantTemporalEvent(args[0]): start, finish, start_formatted, finish_formatted = parseTemporalEvent(args[0], user) else: format = None times = args[0:2] if len(times) == 1: finish, format = parseDatetime(times[0], True) elif len(times) >= 2: start, format = parseDatetime(times[0], True) finish, format = parseDatetime(times[1], True) if options.start: start, format = parseDatetime(options.start, True) if options.finish: finish, format = parseDatetime(options.finish, True) if format: start_formatted = start.strftime(format) finish_formatted = finish.strftime(format) output_type = options.output if options.output else user.output ratio = clamp(calculate_ratio(start, finish), 0.0, 1.0) return start_formatted, finish_formatted, ratio, output_type class Event(object): Time = property(fget=lambda self: self._time) Format = property(fget=lambda self: self._format) def __init__(self, time, format=None): self._time = time self._format = format def __str__(self): return self._time.strftime(self._format) class EventImportant(Event): pass class Output(object): def perc(self, ratio, start, finish): print CONFIG["OUTPUT"]["PERCENTAGE"].format(ratio=ratio, start=start, finish=finish) def bar(self, ratio, start, finish): length = CONFIG["OUTPUT"]["BAR"]["length"] if not options.mobile else CONFIG["OUTPUT"]["BAR"]["length_mobile"] char = CONFIG["OUTPUT"]["BAR"]["character"] current = clamp(int(ratio * length), 0, length) space = " " * (length - current) if space: space = ">" + space[1:] ratiostr = "{0}%".format(round_to_sigfig(ratio * 100, CONFIG["RATIO_SIGFIGS"])) if current >= 7 else "" print "{start} [{ratio:{char}^{current}}{space}] {finish}".format(ratio=ratiostr, char=char, current=current, space=space, start=start, finish=finish) def ram(self, ratio, start, finish): # 10 raised arm men indicates a complete day # Raised arm men parts (3 parts per raised arm man and 10 of them) ram_parts = 1.0 / (3.0 * 10.0) print "Not implemented" def pint(self, ratio, start, finish): print "Not implemented" def output(self, type, ratio, start, finish): func = getattr(self, type, self.perc) func(ratio, start, finish) class TimeRange(object): def __init__(self, start, finish, user): self._start = start self._finish = finish self.user = user def __datetimeGetter(self, name): return datetime.combine(NOW.date(), datetime.strptime(getattr(self, name), "%H:%M").time()) def __datetimeSetter(self, name, value): self._data[name] = value @property def start(self): return self.__datetimeGetter("_start") @start.setter def start(self, value): self.__datetimeSetter("_start", value) @property def finish(self): return self.__datetimeGetter("_finish") @finish.setter def finish(self, value): self.__datetimeSetter("_finish", value) @property def format(self): return self.user.format def toDict(self): return { "start": self._start, "finish": self._finish } def __copy__(self): return TimeRange(self._start, self._finish, self.user) def __deepcopy__(self, memo): return TimeRange(self._start, self._finish, self.user) class User(object): PROPERTIES = ["start", "finish", "format", "output", "lunch"] Name = property(fget=lambda self: self._name, fset=lambda self, value: setattr(self, "_name", value)) def __init__(self, name, data=None): self._name = name.lower() if data: self.data = data else: try: self.data = self.__parseData(CONFIG["DEFAULTS"][name]) except KeyError: self.data = {} def __parseData(self, data): data = copy.deepcopy(data) for key, value in data.items(): if isinstance(value, dict) and set(["start", "finish"]).issubset(set(value.keys())): data[key] = TimeRange(value["start"], value["finish"], self) return data def __convertToDict(self): data = copy.deepcopy(self.data) for key, value in data.items(): if isinstance(value, TimeRange): data[key] = value.toDict() return data def commit(self): if self.Name in CONFIG["DEFAULTS"] and USER.Name != self.Name: print "Cannot edit someone else's perc settings!" sys.exit(1) if self.Name == "default": print "Cannot edit default user" sys.exit(1) CONFIG["DEFAULTS"][self.Name] = self.__convertToDict() print "User updated in config: '%s'" % self.Name global CONFIG_IS_DIRTY CONFIG_IS_DIRTY = True def __datetimeGetter(self, name): try: return datetime.combine(NOW.date(), datetime.strptime(self.data[name], "%H:%M").time()) except KeyError: return getattr(DEFAULT_USER, name) def __defaultSetter(self, name, value): self.data[name] = value def __defaultDeleter(self, name): try: del self.data[name] except KeyError: pass def __defaultGetter(self, name): try: return self.data[name] except KeyError: return getattr(DEFAULT_USER, name) def __getattr__(self, name): if name in User.PROPERTIES: if name in ["start", "finish"]: return self.__datetimeGetter(name) else: return self.__defaultGetter(name) else: return self.__getattribute__(name) def __setattr__(self, name, value): if name in User.PROPERTIES: self.__defaultSetter(name, value) else: super(User, self).__setattr__(name, value) def __delattr__(self, name): if name in User.PROPERTIES: self.__defaultDeleter(name) else: super(User, self).__delattr__(name) def __copy__(self): return User(copy.copy(self.data)) def __deepcopy__(self, memo): return User(self.Name, copy.deepcopy(self.data)) def admin(args): if USER.Name not in CONFIG["ADMINS"]: print "You do not have admin permissions" sys.exit(1) global options parser = optparse.OptionParser() parser.add_option("-v", "--version", action="store_true", dest="version", help="Display the version of %s" % PROG) parser.add_option("-a", "--add-event", action="store", dest="add_event", help="Add the named event to {prog} to be used in the usual !{prog} way".format(prog=PROG)) parser.add_option("-u", "--user", action="store", dest="user", help="Reference the named user. Used in tandem with -l and -s and -f commands. If only used with -s and/or -f then sets start and finish times of entire day for user. If used with -l and -s and/or -f commands sets the lunch times of that user.") parser.add_option("-c", "--clone", action="store", dest="clone", help="") parser.add_option("-l", "--lunch", action="store_true", default=False, dest="lunch", help="Indicate that lunch times are to be set.") parser.add_option("-r", "--remove-event", action="store", dest="remove_event", help="Remove a named event from %s" % PROG) parser.add_option("-s", "--start", action="store", dest="start", help="Used in conjunction with -a/--add-event option. The start date/time of the event.") parser.add_option("-f", "--finish", action="store", dest="finish", help="Used in conjunction with -a/--add-event option. The finish date/time of the event.") parser.add_option("-p", "--format", action="store", default="", dest="format", help="Used in conjunction with -a/--add-event option. The format of the printed event date/time, uses python strftime formatting and accepts {name} keyword to display name of event.") parser.add_option("-o", "--output", action="store", type="choice", choices=OUTPUT_TYPES, dest="output", help="The output display type for the user. Options are %s" % ", ".join(OUTPUT_TYPES)) (options, args) = parser.parse_args(args) if options.version: print "v%s" % VERSION sys.exit(0) if options.user: user = None if options.clone: user = copy.deepcopy(User(options.clone)) user.Name = options.user else: user = User(options.user) if options.lunch: lunch_start = "13:00" lunch_finish = "14:00" if options.start: lunch_start, f = parseTime(options.start) lunch_start = lunch_start.strftime("%H:%M") if options.finish: lunch_finish, f = parseTime(options.finish) lunch_finish = lunch_finish.strftime("%H:%M") lunch = TimeRange(lunch_start, lunch_finish, user) user.lunch = lunch else: if options.start: start, f = parseTime(options.start) user.start = start.strftime("%H:%M") if options.finish: finish, f = parseTime(options.finish) user.finish = finish.strftime("%H:%M") if options.format: user.format = options.format if options.output: user.output = options.output user.commit() else: if options.add_event: if not options.start or not options.finish: print "--add-event option requires --start and --finish date/time options to be set" sys.exit(1) name = options.add_event start, f = parseDatetime(options.start) finish, f = parseDatetime(options.finish) format = options.format if options.format else "{name} %Y" addEvent(name, start, finish, format) if options.remove_event: removeEvent(options.remove_event) if CONFIG_IS_DIRTY: with open(CONFIG_FILE, "w") as f: json.dump(CONFIG, f, indent=4) def user(args): global options parser = optparse.OptionParser("{0} [options] | {0} [options]".format(PROG)) parser.add_option("-v", "--version", action="store_true", dest="version", help="Display the version of %s" % PROG) parser.add_option("-s", "--start", action="store", dest="start", help="The start time of your particular day") parser.add_option("-f", "--finish", action="store", dest="finish", help="The finish time of your particular day") parser.add_option("-n", "--nick", action="store", dest="nick", help="Nickname of someone (e.g. fuckwad, l_bratch, bikeman or something)") parser.add_option("-o", "--output", action="store", type="choice", choices=OUTPUT_TYPES, dest="output", help="Output display type. Options are %s" % ", ".join(OUTPUT_TYPES)) parser.add_option("-m", "--mobile", action="store_true", default=False, dest="mobile", help="Indicate this should be displayed appropriately for a mobile device (i.e. smaller character display width)") (options, args) = parser.parse_args(args) if isWeekend(): print "It's the freekin' Weekend!!!!" sys.exit(0) if options.version: print "v%s" % VERSION sys.exit(0) # Choose which user to display percentages for user = USER if options.nick: nick = options.nick.lower() user = User(nick) start, finish, ratio, output_type = parseUserArgs(args, user) # Finally output the percentage in the specified way output = Output() output.output(output_type, ratio, start, finish) def main(): readConfig() args = sys.argv[1:] if "perc" in args: args.remove("perc") global DEFAULT_USER DEFAULT_USER = User("default") # Set the user variable to the user who ran the script global USER USER = User("default") if args: nick = args.pop(0).lower() USER = User(nick) # Obtain the mode if set, otherwise default to 'user' mode = "user" if args: if args[0] in MODES: mode = args.pop(0) if mode == "user": user(args) elif mode == "admin": admin(args) else: print "Not a recognised mode '%s'" % mode sys.exit(1) if __name__ == "__main__": main()