#!/usr/bin/env python import datetime import textwrap import re import string import calendar as cal from optparse import OptionParser, OptionValueError, IndentedHelpFormatter from os.path import expanduser, exists from os import environ from sys import argv, stderr, exit VERSION = "$Revision: 1.1 $" VERSION = VERSION.strip("$").split(":")[1].strip() RC_1 = "~/.mencalrc" RC_2 = "~/.mencal2rc" date_re = re.compile(r"(\d{4})(\d\d)(\d\d)") SOME_SUNDAY = datetime.date(1900, 1, 7) # an arbitrary Sunday DAYS = [ (SOME_SUNDAY + datetime.timedelta(days=n)).strftime('%a') for n in range(7) ] class ConfigurationException(Exception): pass class Colors(object): BLACK = 'black' RED = "red" GREEN = "green" BLUE = "blue" YELLOW = "yellow" MAGENTA = "magenta" CYAN = "cyan" WHITE = "white" DEFAULT = WHITE ALL = [ BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, ] def __init__(self): self.ansi_map = {} for name in self: orig_name = name if name.startswith('bright'): code = '1;' name = name[6:] # chop off the "bright" else: code = '' code += str(Colors.ALL.index(name) + 30) self.ansi_map[orig_name] = code self.ansi = chr(27) + '[%sm' def __iter__(cls): for prefix in ('', 'bright'): for item in Colors.ALL: yield '%s%s' % (prefix, item) def __contains__(self, name): if name.startswith('bright'): name = name[6:] # chop off the "bright" return name in Colors.ALL def __getitem__(self, name): return Colors.DEFAULT def make_color(self, options, s, color): pre = self.ansi % self.ansi_map[color] post = self.ansi % '0' return pre + s + post Colors = Colors() class IndentedHelpFormatterWithNL(IndentedHelpFormatter): def format_description(self, description): if not description: return "" desc_width = self.width - self.current_indent indent = " "*self.current_indent bits = description.split('\n') formatted_bits = [ textwrap.fill(bit, desc_width, initial_indent=indent, subsequent_indent=indent) for bit in bits] result = "\n".join(formatted_bits) + "\n" return result def format_option(self, option): # The help for each option consists of two parts: # * the opt strings and metavars # eg. ("-x", or "-fFILENAME, --file=FILENAME") # * the user-supplied help string # eg. ("turn on expert mode", "read data from FILENAME") # # If possible, we write both of these on the same line: # -x turn on expert mode # # But if the opt string list is too long, we put the help # string on a second line, indented to the same column it would # start in if it fit on the first line. # -fFILENAME, --file=FILENAME # read data from FILENAME result = [] opts = self.option_strings[option] opt_width = self.help_position - self.current_indent - 2 if len(opts) > opt_width: opts = "%*s%s\n" % (self.current_indent, "", opts) indent_first = self.help_position else: # start help on same line as opts opts = "%*s%-*s " % (self.current_indent, "", opt_width, opts) indent_first = 0 result.append(opts) if option.help: help_text = self.expand_default(option) # Everything is the same up through here help_lines = [] for para in help_text.split("\n"): help_lines.extend(textwrap.wrap(para, self.help_width)) # Everything is the same after here result.append("%*s%s\n" % (indent_first, "", help_lines[0])) result.extend(["%*s%s\n" % (self.help_position, "", line) for line in help_lines[1:]]) elif opts[-1] != "\n": result.append("\n") return "".join(result) # defaults DEFAULT_NAME = "Default" DEFAULT_LENGTH = 7 DEFAULT_PHASE = 28 DEFAULT_PMS = 5 DEFAULT_COLOR = "brightred" DEFAULT_PMS_COLOR = "red" # field names LENGTH = "length" START = "start" DURATION = "duration" COLOR = "color" ICOLOR = "icolor" NAME = "name" PMS = "pms" OPT_QUIET = "quiet" OPT_VERBOSE = "verbose" OPT_MONTHS = "months" OPT_YEAR = "year" OPT_MONDAY_FIRST = "monday_first" OPT_BW = "bw" OPT_ICOLOR = "icolor" OPT_PERSON = "person" OPT_PEOPLE = "people" COLOR_VAR = "COLOR" CONF = "CONF" def iter_splitter(iterator, clumps): try: while True: sub_items = [] for _ in range(clumps): sub_items.append(iterator.next()) yield sub_items except StopIteration: # deal with the last row if sub_items: yield sub_items raise StopIteration def callback_year(option, opt_str, value, parser): rargs = parser.rargs year = datetime.date.today().year try: year = int(rargs[0]) del rargs[0] except: pass if not (datetime.MINYEAR <= year <= datetime.MAXYEAR): raise OptionValueError("Invalid year [%i]" % year) setattr(parser.values, option.dest, year) def callback_person(option, opt_str, value, parser): options = dict(s.split('=',1) for s in value.split(',')) if "s" not in options: raise OptionValueError("No start-date given") d = options["s"] now = datetime.date.today() if len(d) <= 2: d = "%04i%02i%s" % (now.year, now.month, d.zfill(2)) elif len(d) <= 4: d = "%04i%s" % (now.year, d.zfill(4)) d = parse_date(d) p = Person(d, options.get(NAME[0], DEFAULT_NAME), options.get(DURATION[0], DEFAULT_LENGTH), options.get(LENGTH[0], DEFAULT_PHASE), options.get(PMS[0], DEFAULT_PMS), options.get(COLOR[0], DEFAULT_COLOR) ) parser.values.people[p.name.lower()] = p class Person(object): def __init__(self, start, name=DEFAULT_NAME, duration=DEFAULT_LENGTH, length=DEFAULT_PHASE, pms = DEFAULT_PMS, color=DEFAULT_COLOR, **kwargs): self.name = name self.start = start self.duration = float(duration) self.length = int(length) self.pms = int(pms) self.color = color def _adjust_delta(self, delta): if delta < 0: return self.length + delta return delta def _set_length(self, length): assert int(length) > 0 self._length = length def _get_length(self): return self._length length = property(fget=_get_length, fset=_set_length, doc="The phase of the period") def _set_duration(self, duration): assert int(duration) > 0 self._duration = duration def _get_duration(self): return self._duration duration = property(fget=_get_duration, fset=_set_duration, doc="The duration of the period") def _set_pms_days(self, pms): assert int(pms) > 0 self._pms_days = pms def _get_pms_days(self): return self._pms_days pms = property(fget=_get_pms_days, fset=_set_pms_days, doc="The number of PMS days preceeding") def is_period_day(self, d): delta = self._adjust_delta((d - self.start).days) return self.is_period_offset(delta) def is_period_offset(self, offset): return offset % self.length <= self.duration def is_pms_day(self, d): delta = self._adjust_delta((d - self.start).days) return self.is_pms_offset(delta) def is_pms_offset(self, offset): result = offset % self.length >= self.length - self.pms return result def get_day_info(self, d): delta = self._adjust_delta((d - self.start).days) return self.is_pms_offset(delta), self.is_period_offset(delta) def __str__(self): return self.name def __repr__(self): today = datetime.date.today() s = str(self) for i in xrange(self.length): if self.is_period_day(today + datetime.timedelta(days=i)): if i == 0: s += " (currently having)" else: s += " (period begins in %i day" % (self, i) if i <> 1: s += "s" s += ")" return "<%s>" % s return "<%s>" % s def parse_params(args): people = [] parser = OptionParser( formatter=IndentedHelpFormatterWithNL(), usage="%prog [options] [NAME1 [NAME2 [...]]] [MONTH [YEAR]]", description= "Displays one or more months of calendars with " "PMS and menstruation info.\n\n" "NAME is a case-insensitive name given " "on the command line or specified in " "either the ~/.mencalrc or ~/.mencal2rc\n\n" "%(color)s is one of:\n" " [%(colors)s]" % { "color": COLOR, "colors": ", ".join(iter(Colors)), }, version=VERSION, ) parser.add_option("-q", "--quiet", action="store_true", dest=OPT_QUIET, help="no top information will be printed" ) parser.add_option("-v", "--verbose", action="count", dest=OPT_VERBOSE, help="increase verbosity" ) parser.add_option("-1", action="store_const", dest=OPT_MONTHS, const=1, help="show current month (default)" ) parser.add_option("-3", action="store_const", const=3, dest=OPT_MONTHS, help="show previous, current, and next month" ) parser.add_option("-y", "--year", action="callback", callback=callback_year, dest=OPT_YEAR, nargs=0, # 0 to let the callback eat the param optionally type="int", # int to force the metavar to show up in the help metavar="YEAR", help="show the entire year" ) # parser.add_option("-m", "--monday", # action="store_true", # dest=OPT_MONDAY_FIRST, # help="draw Monday as the first weekday (Sunday is the default)" # ) parser.add_option("-n", "--nocolor", action="store_true", dest=OPT_BW, help="don't color the results" ) parser.add_option("-i", "--icolor", "--intersection", "--intersection-color", choices=list(Colors), dest=OPT_ICOLOR, metavar=COLOR_VAR, help="the color used to display the intersection of multiple days", ) # parser.add_option("-c", "--config", # action="callback", # callback=callback_person, # nargs=1, # type="string", # dest="person", # metavar=CONF, # help="a comma-separated list of options.\n" # "s=[YYYY]MMDD,l=LL,d=DD,p=PMS,n=NAME,c=COLOR\n" # "s,start=[YYYY]MMDD\n" # " start day of period (default, current day)\n" # "l,length=LL\n" # " length of the phase (default %i)\n" # "d,duration=D\n" # " duration of menstruation in days (default %i)\n" # "p,pms=PMS\n" # " days of PMS (default %i)\n" # % (DEFAULT_PHASE, # DEFAULT_LENGTH, # DEFAULT_PMS, # ) # ) parser.set_defaults(**{ OPT_MONTHS: 1, OPT_ICOLOR: "brightwhite", OPT_BW: False, OPT_QUIET: False, OPT_VERBOSE: 0, OPT_MONDAY_FIRST: False, OPT_PERSON: None, OPT_PEOPLE: {}, } ) (options, args) = parser.parse_args() return options.people.values(), options.__dict__, args def parse_date(s): m = date_re.match(s.strip()) if m: year, month, day = m.groups() return datetime.date( int(year), int(month.lstrip("0")), int(day.lstrip("0")) ) else: raise ValueError('Invalid date: "%s"' % s) def get_config_value(line): if "=" in line: pair = line.strip("\n").split("=", 1) else: pair = line.strip("\n").split(None, 1) try: key, value = map(string.strip, pair) key = key.lower() if key.startswith(START[0]): return START, parse_date(value) elif key.startswith(LENGTH[0]): return LENGTH, int(value) elif key.startswith(DURATION[0]): return DURATION, int(value) elif key.startswith(PMS[0]): return PMS, int(value) elif key.startswith(NAME[0]): return NAME, value.strip() elif key.startswith(COLOR[0]) and value in Colors: return COLOR, value.strip() elif key.startswith(ICOLOR[0]) and value in Colors: return ICOLOR, value.strip() except: pass stderr.write("Unknown configuration option: %s (ignoring)\n" % key) return "", "" def parse_old_config_file(f): if not exists(f): return [], {} value_map = {} for line in file(f): line = line.strip() if line.startswith("#") or not line: continue k,v = get_config_value(line) if k: value_map[k] = v if START not in value_map: raise ConfigurationException("No start date specified in %s" % f) p = Person(value_map[START], value_map.get(NAME, DEFAULT_NAME), value_map.get(DURATION, DEFAULT_LENGTH), value_map.get(LENGTH, DEFAULT_PHASE), DEFAULT_PMS, value_map.get(COLOR, DEFAULT_COLOR)) return [p], {} def extract_global_options(options): results = {} if ICOLOR in options: results[ICOLOR] = options[ICOLOR] return results def parse_new_config_file(f): people = [] options = {} if not exists(f): return people, options value_map = {} for line in file(f): line = line.strip() if line.startswith("#") or line.startswith(";") or not line: continue if line.startswith("[") and line.endswith("]"): name = line.lstrip("[").rstrip("]") new_options = extract_global_options(value_map) options.update(new_options) if START in value_map: p = Person(value_map[START], value_map.get(NAME, DEFAULT_NAME), value_map.get(DURATION, DEFAULT_LENGTH), value_map.get(LENGTH, DEFAULT_PHASE), value_map.get(PMS, DEFAULT_PMS), value_map.get(COLOR, DEFAULT_COLOR)) people.append(p) value_map = {NAME: name} else: k,v = get_config_value(line) if k: value_map[k] = v if START not in value_map and not people: raise ConfigurationException("No start date specified in %s" % f) p = Person(value_map[START], value_map.get(NAME, DEFAULT_NAME), value_map.get(DURATION, DEFAULT_LENGTH), value_map.get(LENGTH, DEFAULT_PHASE), value_map.get(PMS, DEFAULT_PMS), value_map.get(COLOR, DEFAULT_COLOR)) people.append(p) new_options = extract_global_options(value_map) options.update(new_options) return people, options def decorate_day_bw(options, date, people, width): def letter(person): """ if PMS, lowercase, if period, upper, else blank """ initial = person.name[0] if person.is_pms_day(date): return initial.lower() elif person.is_period_day(date): return initial.upper() return '' initials = ''.join(letter(person) for person in people ).ljust(width-2)[:width-2] day_str = str(date.strftime('%e')) return "%s%s" % (day_str, initials) def decorate_day_color(options, date, people, width): pms_days = period_days = 0 for person in people: if person.is_pms_day(date): pms_days += 1 if person.is_period_day(date): period_days += 1 last_person = person if period_days > 1: color = options[OPT_ICOLOR] elif period_days == 1: color = last_person.color elif pms_days: color = DEFAULT_PMS_COLOR elif date == datetime.date.today(): color = 'bright' + Colors.DEFAULT else: color = Colors.DEFAULT day_str = str(date.strftime('%e')) return Colors.make_color(options, day_str, color) def month_cal(options, year, month, people, header_format="%B %Y", ): row_decorator = lambda s: s if options[OPT_BW]: decorate_day=decorate_day_bw col_width = 2 + len(people) else: decorate_day=decorate_day_color col_width = 2 header = datetime.date(year, month, 1).strftime(header_format) week_header = ' '.join(day.ljust(col_width)[:col_width] for day in DAYS) header = header.center(len(week_header)) results = [ header, week_header, ] calendar_iter = cal.Calendar(cal.SUNDAY).itermonthdates(year, month) for week in iter_splitter(calendar_iter, 7): # 7 days/wk results.append(row_decorator(' '.join( decorate_day(options, d, people, col_width) for d in week))) return results if __name__ == "__main__": # build the people/options/args from the # command-line and from RC files people, options, args = parse_params(argv[1:]) if not people: people, options2 = parse_new_config_file(expanduser(RC_2)) options.update(options2) if people: if options[OPT_VERBOSE] > 0: print "Using %s" % RC_2 else: people, options2 = parse_old_config_file(expanduser(RC_1)) if options[OPT_VERBOSE] > 0: print "Using %s" % RC_1 options.update(options2) if not people: stderr.write("Not configured (use --help for more info)\n") exit(1) # parse the args to determine if a particular person or people # are wanted, as well as if a different date name_to_person = dict( (person.name.lower(), person) for person in people ) now = datetime.date.today() month, year = now.month, now.year if args: name = args[0].lower() # if this is a valid name we know about desired_people = set() while args and name in name_to_person and not name.isdigit(): # it's a person, so make them the only person and # remove them from the arguments desired_people.add(name) del args[0] if args: name = args[0].lower() if desired_people: people = [name_to_person[name] for name in desired_people] if args: # if we have any arguments remaining, # they should be the month, # and optionally the year try: month = int(args[0]) assert 1 <= month <= 12 except: stderr.write( """"%s" is neither a valid month nor a known person\n""" % args[0]) exit(1) del args[0] if args: # we still have a year try: year = int(args[0]) if year < 100: # window it to a 4-digit year current_century = int(now.year / 100) * 100 year = current_century + year if year > (now.year + 49): # if it's more than half a century in the future year -= 100 # drop it back a century assert datetime.MINYEAR <= year <= datetime.MAXYEAR except: stderr.write(""""%s" is not a valid year\n""" % args[0]) exit(1) if options["year"]: # print a year calendar year = int(options["year"]) MONTH_SEP = ' | ' cals = {} for m in range(1,13): cals[m] = month_cal(options, year, m, people) screen_width = int(environ.get('COLUMNS', '80')) cal_width = len(cals[1][0]) width = cal_width + len(MONTH_SEP) cals_per_row = int(screen_width / width) if cals_per_row < 1: cals_per_row = 1 lines = [] for bunch in iter_splitter(iter(xrange(1,13)), cals_per_row): if lines: lines.append(MONTH_SEP.join([' '*cal_width] * cals_per_row)) month_row = [cals[i] for i in bunch] longest = len(month_row[0]) for cal in month_row: if len(cal) > longest: longest = len(cal) for cal in month_row: if len(cal) < longest: cal.extend([' '*cal_width] * (longest - len(cal))) merged = zip(*month_row) lines.extend([MONTH_SEP.join(bits) for bits in merged]) elif options["months"] == 3: # print a three-month calendar prev_m = month - 1 next_m = month + 1 prev_y = next_y = year if prev_m < 1: prev_y -= 1 prev_m = 12 if next_m > 12: next_y += 1 next_m = 1 lines = month_cal(options, prev_y, prev_m, people) lines.append('') lines.extend(month_cal(options, year, month, people)) lines.append('') lines.extend(month_cal(options, next_y, next_m, people)) else: # print a month calendar lines = month_cal(options, year, month, people) if not options[OPT_QUIET]: topline = ", ".join( "%s (%s)" % (person.name, person.name[0].upper()) for person in people) print topline.center(len(lines[0])) print '=' * len(lines[0]) print '\n'.join(lines)