1"""Calendar printing functions
2
3Note when comparing these calendars to the ones printed by cal(1): By
4default, these calendars have Monday as the first day of the week, and
5Sunday as the last (the European convention). Use setfirstweekday() to
6set the first day of the week (0=Monday, 6=Sunday)."""
7
8import sys
9import datetime
10import locale as _locale
11
12__all__ = ["IllegalMonthError", "IllegalWeekdayError", "setfirstweekday",
13           "firstweekday", "isleap", "leapdays", "weekday", "monthrange",
14           "monthcalendar", "prmonth", "month", "prcal", "calendar",
15           "timegm", "month_name", "month_abbr", "day_name", "day_abbr"]
16
17# Exception raised for bad input (with string parameter for details)
18error = ValueError
19
20# Exceptions raised for bad input
21class IllegalMonthError(ValueError):
22    def __init__(self, month):
23        self.month = month
24    def __str__(self):
25        return "bad month number %r; must be 1-12" % self.month
26
27
28class IllegalWeekdayError(ValueError):
29    def __init__(self, weekday):
30        self.weekday = weekday
31    def __str__(self):
32        return "bad weekday number %r; must be 0 (Monday) to 6 (Sunday)" % self.weekday
33
34
35# Constants for months referenced later
36January = 1
37February = 2
38
39# Number of days per month (except for February in leap years)
40mdays = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
41
42# This module used to have hard-coded lists of day and month names, as
43# English strings.  The classes following emulate a read-only version of
44# that, but supply localized names.  Note that the values are computed
45# fresh on each call, in case the user changes locale between calls.
46
47class _localized_month:
48
49    _months = [datetime.date(2001, i+1, 1).strftime for i in range(12)]
50    _months.insert(0, lambda x: "")
51
52    def __init__(self, format):
53        self.format = format
54
55    def __getitem__(self, i):
56        funcs = self._months[i]
57        if isinstance(i, slice):
58            return [f(self.format) for f in funcs]
59        else:
60            return funcs(self.format)
61
62    def __len__(self):
63        return 13
64
65
66class _localized_day:
67
68    # January 1, 2001, was a Monday.
69    _days = [datetime.date(2001, 1, i+1).strftime for i in range(7)]
70
71    def __init__(self, format):
72        self.format = format
73
74    def __getitem__(self, i):
75        funcs = self._days[i]
76        if isinstance(i, slice):
77            return [f(self.format) for f in funcs]
78        else:
79            return funcs(self.format)
80
81    def __len__(self):
82        return 7
83
84
85# Full and abbreviated names of weekdays
86day_name = _localized_day('%A')
87day_abbr = _localized_day('%a')
88
89# Full and abbreviated names of months (1-based arrays!!!)
90month_name = _localized_month('%B')
91month_abbr = _localized_month('%b')
92
93# Constants for weekdays
94(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7)
95
96
97def isleap(year):
98    """Return True for leap years, False for non-leap years."""
99    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
100
101
102def leapdays(y1, y2):
103    """Return number of leap years in range [y1, y2).
104       Assume y1 <= y2."""
105    y1 -= 1
106    y2 -= 1
107    return (y2//4 - y1//4) - (y2//100 - y1//100) + (y2//400 - y1//400)
108
109
110def weekday(year, month, day):
111    """Return weekday (0-6 ~ Mon-Sun) for year (1970-...), month (1-12),
112       day (1-31)."""
113    return datetime.date(year, month, day).weekday()
114
115
116def monthrange(year, month):
117    """Return weekday (0-6 ~ Mon-Sun) and number of days (28-31) for
118       year, month."""
119    if not 1 <= month <= 12:
120        raise IllegalMonthError(month)
121    day1 = weekday(year, month, 1)
122    ndays = mdays[month] + (month == February and isleap(year))
123    return day1, ndays
124
125
126class Calendar(object):
127    """
128    Base calendar class. This class doesn't do any formatting. It simply
129    provides data to subclasses.
130    """
131
132    def __init__(self, firstweekday=0):
133        self.firstweekday = firstweekday # 0 = Monday, 6 = Sunday
134
135    def getfirstweekday(self):
136        return self._firstweekday % 7
137
138    def setfirstweekday(self, firstweekday):
139        self._firstweekday = firstweekday
140
141    firstweekday = property(getfirstweekday, setfirstweekday)
142
143    def iterweekdays(self):
144        """
145        Return a iterator for one week of weekday numbers starting with the
146        configured first one.
147        """
148        for i in range(self.firstweekday, self.firstweekday + 7):
149            yield i%7
150
151    def itermonthdates(self, year, month):
152        """
153        Return an iterator for one month. The iterator will yield datetime.date
154        values and will always iterate through complete weeks, so it will yield
155        dates outside the specified month.
156        """
157        date = datetime.date(year, month, 1)
158        # Go back to the beginning of the week
159        days = (date.weekday() - self.firstweekday) % 7
160        date -= datetime.timedelta(days=days)
161        oneday = datetime.timedelta(days=1)
162        while True:
163            yield date
164            try:
165                date += oneday
166            except OverflowError:
167                # Adding one day could fail after datetime.MAXYEAR
168                break
169            if date.month != month and date.weekday() == self.firstweekday:
170                break
171
172    def itermonthdays2(self, year, month):
173        """
174        Like itermonthdates(), but will yield (day number, weekday number)
175        tuples. For days outside the specified month the day number is 0.
176        """
177        for date in self.itermonthdates(year, month):
178            if date.month != month:
179                yield (0, date.weekday())
180            else:
181                yield (date.day, date.weekday())
182
183    def itermonthdays(self, year, month):
184        """
185        Like itermonthdates(), but will yield day numbers. For days outside
186        the specified month the day number is 0.
187        """
188        for date in self.itermonthdates(year, month):
189            if date.month != month:
190                yield 0
191            else:
192                yield date.day
193
194    def monthdatescalendar(self, year, month):
195        """
196        Return a matrix (list of lists) representing a month's calendar.
197        Each row represents a week; week entries are datetime.date values.
198        """
199        dates = list(self.itermonthdates(year, month))
200        return [ dates[i:i+7] for i in range(0, len(dates), 7) ]
201
202    def monthdays2calendar(self, year, month):
203        """
204        Return a matrix representing a month's calendar.
205        Each row represents a week; week entries are
206        (day number, weekday number) tuples. Day numbers outside this month
207        are zero.
208        """
209        days = list(self.itermonthdays2(year, month))
210        return [ days[i:i+7] for i in range(0, len(days), 7) ]
211
212    def monthdayscalendar(self, year, month):
213        """
214        Return a matrix representing a month's calendar.
215        Each row represents a week; days outside this month are zero.
216        """
217        days = list(self.itermonthdays(year, month))
218        return [ days[i:i+7] for i in range(0, len(days), 7) ]
219
220    def yeardatescalendar(self, year, width=3):
221        """
222        Return the data for the specified year ready for formatting. The return
223        value is a list of month rows. Each month row contains upto width months.
224        Each month contains between 4 and 6 weeks and each week contains 1-7
225        days. Days are datetime.date objects.
226        """
227        months = [
228            self.monthdatescalendar(year, i)
229            for i in range(January, January+12)
230        ]
231        return [months[i:i+width] for i in range(0, len(months), width) ]
232
233    def yeardays2calendar(self, year, width=3):
234        """
235        Return the data for the specified year ready for formatting (similar to
236        yeardatescalendar()). Entries in the week lists are
237        (day number, weekday number) tuples. Day numbers outside this month are
238        zero.
239        """
240        months = [
241            self.monthdays2calendar(year, i)
242            for i in range(January, January+12)
243        ]
244        return [months[i:i+width] for i in range(0, len(months), width) ]
245
246    def yeardayscalendar(self, year, width=3):
247        """
248        Return the data for the specified year ready for formatting (similar to
249        yeardatescalendar()). Entries in the week lists are day numbers.
250        Day numbers outside this month are zero.
251        """
252        months = [
253            self.monthdayscalendar(year, i)
254            for i in range(January, January+12)
255        ]
256        return [months[i:i+width] for i in range(0, len(months), width) ]
257
258
259class TextCalendar(Calendar):
260    """
261    Subclass of Calendar that outputs a calendar as a simple plain text
262    similar to the UNIX program cal.
263    """
264
265    def prweek(self, theweek, width):
266        """
267        Print a single week (no newline).
268        """
269        print self.formatweek(theweek, width),
270
271    def formatday(self, day, weekday, width):
272        """
273        Returns a formatted day.
274        """
275        if day == 0:
276            s = ''
277        else:
278            s = '%2i' % day             # right-align single-digit days
279        return s.center(width)
280
281    def formatweek(self, theweek, width):
282        """
283        Returns a single week in a string (no newline).
284        """
285        return ' '.join(self.formatday(d, wd, width) for (d, wd) in theweek)
286
287    def formatweekday(self, day, width):
288        """
289        Returns a formatted week day name.
290        """
291        if width >= 9:
292            names = day_name
293        else:
294            names = day_abbr
295        return names[day][:width].center(width)
296
297    def formatweekheader(self, width):
298        """
299        Return a header for a week.
300        """
301        return ' '.join(self.formatweekday(i, width) for i in self.iterweekdays())
302
303    def formatmonthname(self, theyear, themonth, width, withyear=True):
304        """
305        Return a formatted month name.
306        """
307        s = month_name[themonth]
308        if withyear:
309            s = "%s %r" % (s, theyear)
310        return s.center(width)
311
312    def prmonth(self, theyear, themonth, w=0, l=0):
313        """
314        Print a month's calendar.
315        """
316        print self.formatmonth(theyear, themonth, w, l),
317
318    def formatmonth(self, theyear, themonth, w=0, l=0):
319        """
320        Return a month's calendar string (multi-line).
321        """
322        w = max(2, w)
323        l = max(1, l)
324        s = self.formatmonthname(theyear, themonth, 7 * (w + 1) - 1)
325        s = s.rstrip()
326        s += '\n' * l
327        s += self.formatweekheader(w).rstrip()
328        s += '\n' * l
329        for week in self.monthdays2calendar(theyear, themonth):
330            s += self.formatweek(week, w).rstrip()
331            s += '\n' * l
332        return s
333
334    def formatyear(self, theyear, w=2, l=1, c=6, m=3):
335        """
336        Returns a year's calendar as a multi-line string.
337        """
338        w = max(2, w)
339        l = max(1, l)
340        c = max(2, c)
341        colwidth = (w + 1) * 7 - 1
342        v = []
343        a = v.append
344        a(repr(theyear).center(colwidth*m+c*(m-1)).rstrip())
345        a('\n'*l)
346        header = self.formatweekheader(w)
347        for (i, row) in enumerate(self.yeardays2calendar(theyear, m)):
348            # months in this row
349            months = range(m*i+1, min(m*(i+1)+1, 13))
350            a('\n'*l)
351            names = (self.formatmonthname(theyear, k, colwidth, False)
352                     for k in months)
353            a(formatstring(names, colwidth, c).rstrip())
354            a('\n'*l)
355            headers = (header for k in months)
356            a(formatstring(headers, colwidth, c).rstrip())
357            a('\n'*l)
358            # max number of weeks for this row
359            height = max(len(cal) for cal in row)
360            for j in range(height):
361                weeks = []
362                for cal in row:
363                    if j >= len(cal):
364                        weeks.append('')
365                    else:
366                        weeks.append(self.formatweek(cal[j], w))
367                a(formatstring(weeks, colwidth, c).rstrip())
368                a('\n' * l)
369        return ''.join(v)
370
371    def pryear(self, theyear, w=0, l=0, c=6, m=3):
372        """Print a year's calendar."""
373        print self.formatyear(theyear, w, l, c, m)
374
375
376class HTMLCalendar(Calendar):
377    """
378    This calendar returns complete HTML pages.
379    """
380
381    # CSS classes for the day <td>s
382    cssclasses = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
383
384    def formatday(self, day, weekday):
385        """
386        Return a day as a table cell.
387        """
388        if day == 0:
389            return '<td class="noday">&nbsp;</td>' # day outside month
390        else:
391            return '<td class="%s">%d</td>' % (self.cssclasses[weekday], day)
392
393    def formatweek(self, theweek):
394        """
395        Return a complete week as a table row.
396        """
397        s = ''.join(self.formatday(d, wd) for (d, wd) in theweek)
398        return '<tr>%s</tr>' % s
399
400    def formatweekday(self, day):
401        """
402        Return a weekday name as a table header.
403        """
404        return '<th class="%s">%s</th>' % (self.cssclasses[day], day_abbr[day])
405
406    def formatweekheader(self):
407        """
408        Return a header for a week as a table row.
409        """
410        s = ''.join(self.formatweekday(i) for i in self.iterweekdays())
411        return '<tr>%s</tr>' % s
412
413    def formatmonthname(self, theyear, themonth, withyear=True):
414        """
415        Return a month name as a table row.
416        """
417        if withyear:
418            s = '%s %s' % (month_name[themonth], theyear)
419        else:
420            s = '%s' % month_name[themonth]
421        return '<tr><th colspan="7" class="month">%s</th></tr>' % s
422
423    def formatmonth(self, theyear, themonth, withyear=True):
424        """
425        Return a formatted month as a table.
426        """
427        v = []
428        a = v.append
429        a('<table border="0" cellpadding="0" cellspacing="0" class="month">')
430        a('\n')
431        a(self.formatmonthname(theyear, themonth, withyear=withyear))
432        a('\n')
433        a(self.formatweekheader())
434        a('\n')
435        for week in self.monthdays2calendar(theyear, themonth):
436            a(self.formatweek(week))
437            a('\n')
438        a('</table>')
439        a('\n')
440        return ''.join(v)
441
442    def formatyear(self, theyear, width=3):
443        """
444        Return a formatted year as a table of tables.
445        """
446        v = []
447        a = v.append
448        width = max(width, 1)
449        a('<table border="0" cellpadding="0" cellspacing="0" class="year">')
450        a('\n')
451        a('<tr><th colspan="%d" class="year">%s</th></tr>' % (width, theyear))
452        for i in range(January, January+12, width):
453            # months in this row
454            months = range(i, min(i+width, 13))
455            a('<tr>')
456            for m in months:
457                a('<td>')
458                a(self.formatmonth(theyear, m, withyear=False))
459                a('</td>')
460            a('</tr>')
461        a('</table>')
462        return ''.join(v)
463
464    def formatyearpage(self, theyear, width=3, css='calendar.css', encoding=None):
465        """
466        Return a formatted year as a complete HTML page.
467        """
468        if encoding is None:
469            encoding = sys.getdefaultencoding()
470        v = []
471        a = v.append
472        a('<?xml version="1.0" encoding="%s"?>\n' % encoding)
473        a('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n')
474        a('<html>\n')
475        a('<head>\n')
476        a('<meta http-equiv="Content-Type" content="text/html; charset=%s" />\n' % encoding)
477        if css is not None:
478            a('<link rel="stylesheet" type="text/css" href="%s" />\n' % css)
479        a('<title>Calendar for %d</title>\n' % theyear)
480        a('</head>\n')
481        a('<body>\n')
482        a(self.formatyear(theyear, width))
483        a('</body>\n')
484        a('</html>\n')
485        return ''.join(v).encode(encoding, "xmlcharrefreplace")
486
487
488class TimeEncoding:
489    def __init__(self, locale):
490        self.locale = locale
491
492    def __enter__(self):
493        self.oldlocale = _locale.getlocale(_locale.LC_TIME)
494        _locale.setlocale(_locale.LC_TIME, self.locale)
495        return _locale.getlocale(_locale.LC_TIME)[1]
496
497    def __exit__(self, *args):
498        _locale.setlocale(_locale.LC_TIME, self.oldlocale)
499
500
501class LocaleTextCalendar(TextCalendar):
502    """
503    This class can be passed a locale name in the constructor and will return
504    month and weekday names in the specified locale. If this locale includes
505    an encoding all strings containing month and weekday names will be returned
506    as unicode.
507    """
508
509    def __init__(self, firstweekday=0, locale=None):
510        TextCalendar.__init__(self, firstweekday)
511        if locale is None:
512            locale = _locale.getdefaultlocale()
513        self.locale = locale
514
515    def formatweekday(self, day, width):
516        with TimeEncoding(self.locale) as encoding:
517            if width >= 9:
518                names = day_name
519            else:
520                names = day_abbr
521            name = names[day]
522            if encoding is not None:
523                name = name.decode(encoding)
524            return name[:width].center(width)
525
526    def formatmonthname(self, theyear, themonth, width, withyear=True):
527        with TimeEncoding(self.locale) as encoding:
528            s = month_name[themonth]
529            if encoding is not None:
530                s = s.decode(encoding)
531            if withyear:
532                s = "%s %r" % (s, theyear)
533            return s.center(width)
534
535
536class LocaleHTMLCalendar(HTMLCalendar):
537    """
538    This class can be passed a locale name in the constructor and will return
539    month and weekday names in the specified locale. If this locale includes
540    an encoding all strings containing month and weekday names will be returned
541    as unicode.
542    """
543    def __init__(self, firstweekday=0, locale=None):
544        HTMLCalendar.__init__(self, firstweekday)
545        if locale is None:
546            locale = _locale.getdefaultlocale()
547        self.locale = locale
548
549    def formatweekday(self, day):
550        with TimeEncoding(self.locale) as encoding:
551            s = day_abbr[day]
552            if encoding is not None:
553                s = s.decode(encoding)
554            return '<th class="%s">%s</th>' % (self.cssclasses[day], s)
555
556    def formatmonthname(self, theyear, themonth, withyear=True):
557        with TimeEncoding(self.locale) as encoding:
558            s = month_name[themonth]
559            if encoding is not None:
560                s = s.decode(encoding)
561            if withyear:
562                s = '%s %s' % (s, theyear)
563            return '<tr><th colspan="7" class="month">%s</th></tr>' % s
564
565
566# Support for old module level interface
567c = TextCalendar()
568
569firstweekday = c.getfirstweekday
570
571def setfirstweekday(firstweekday):
572    try:
573        firstweekday.__index__
574    except AttributeError:
575        raise IllegalWeekdayError(firstweekday)
576    if not MONDAY <= firstweekday <= SUNDAY:
577        raise IllegalWeekdayError(firstweekday)
578    c.firstweekday = firstweekday
579
580monthcalendar = c.monthdayscalendar
581prweek = c.prweek
582week = c.formatweek
583weekheader = c.formatweekheader
584prmonth = c.prmonth
585month = c.formatmonth
586calendar = c.formatyear
587prcal = c.pryear
588
589
590# Spacing of month columns for multi-column year calendar
591_colwidth = 7*3 - 1         # Amount printed by prweek()
592_spacing = 6                # Number of spaces between columns
593
594
595def format(cols, colwidth=_colwidth, spacing=_spacing):
596    """Prints multi-column formatting for year calendars"""
597    print formatstring(cols, colwidth, spacing)
598
599
600def formatstring(cols, colwidth=_colwidth, spacing=_spacing):
601    """Returns a string formatted from n strings, centered within n columns."""
602    spacing *= ' '
603    return spacing.join(c.center(colwidth) for c in cols)
604
605
606EPOCH = 1970
607_EPOCH_ORD = datetime.date(EPOCH, 1, 1).toordinal()
608
609
610def timegm(tuple):
611    """Unrelated but handy function to calculate Unix timestamp from GMT."""
612    year, month, day, hour, minute, second = tuple[:6]
613    days = datetime.date(year, month, 1).toordinal() - _EPOCH_ORD + day - 1
614    hours = days*24 + hour
615    minutes = hours*60 + minute
616    seconds = minutes*60 + second
617    return seconds
618
619
620def main(args):
621    import optparse
622    parser = optparse.OptionParser(usage="usage: %prog [options] [year [month]]")
623    parser.add_option(
624        "-w", "--width",
625        dest="width", type="int", default=2,
626        help="width of date column (default 2, text only)"
627    )
628    parser.add_option(
629        "-l", "--lines",
630        dest="lines", type="int", default=1,
631        help="number of lines for each week (default 1, text only)"
632    )
633    parser.add_option(
634        "-s", "--spacing",
635        dest="spacing", type="int", default=6,
636        help="spacing between months (default 6, text only)"
637    )
638    parser.add_option(
639        "-m", "--months",
640        dest="months", type="int", default=3,
641        help="months per row (default 3, text only)"
642    )
643    parser.add_option(
644        "-c", "--css",
645        dest="css", default="calendar.css",
646        help="CSS to use for page (html only)"
647    )
648    parser.add_option(
649        "-L", "--locale",
650        dest="locale", default=None,
651        help="locale to be used from month and weekday names"
652    )
653    parser.add_option(
654        "-e", "--encoding",
655        dest="encoding", default=None,
656        help="Encoding to use for output"
657    )
658    parser.add_option(
659        "-t", "--type",
660        dest="type", default="text",
661        choices=("text", "html"),
662        help="output type (text or html)"
663    )
664
665    (options, args) = parser.parse_args(args)
666
667    if options.locale and not options.encoding:
668        parser.error("if --locale is specified --encoding is required")
669        sys.exit(1)
670
671    locale = options.locale, options.encoding
672
673    if options.type == "html":
674        if options.locale:
675            cal = LocaleHTMLCalendar(locale=locale)
676        else:
677            cal = HTMLCalendar()
678        encoding = options.encoding
679        if encoding is None:
680            encoding = sys.getdefaultencoding()
681        optdict = dict(encoding=encoding, css=options.css)
682        if len(args) == 1:
683            print cal.formatyearpage(datetime.date.today().year, **optdict)
684        elif len(args) == 2:
685            print cal.formatyearpage(int(args[1]), **optdict)
686        else:
687            parser.error("incorrect number of arguments")
688            sys.exit(1)
689    else:
690        if options.locale:
691            cal = LocaleTextCalendar(locale=locale)
692        else:
693            cal = TextCalendar()
694        optdict = dict(w=options.width, l=options.lines)
695        if len(args) != 3:
696            optdict["c"] = options.spacing
697            optdict["m"] = options.months
698        if len(args) == 1:
699            result = cal.formatyear(datetime.date.today().year, **optdict)
700        elif len(args) == 2:
701            result = cal.formatyear(int(args[1]), **optdict)
702        elif len(args) == 3:
703            result = cal.formatmonth(int(args[1]), int(args[2]), **optdict)
704        else:
705            parser.error("incorrect number of arguments")
706            sys.exit(1)
707        if options.encoding:
708            result = result.encode(options.encoding)
709        print result
710
711
712if __name__ == "__main__":
713    main(sys.argv)
714