• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 an 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 i, d in enumerate(self.itermonthdays(year, month), self.firstweekday):
178            yield d, i % 7
179
180    def itermonthdays(self, year, month):
181        """
182        Like itermonthdates(), but will yield day numbers. For days outside
183        the specified month the day number is 0.
184        """
185        day1, ndays = monthrange(year, month)
186        days_before = (day1 - self.firstweekday) % 7
187        for _ in range(days_before):
188            yield 0
189        for d in range(1, ndays + 1):
190            yield d
191        days_after = (self.firstweekday - day1 - ndays) % 7
192        for _ in range(days_after):
193            yield 0
194
195    def monthdatescalendar(self, year, month):
196        """
197        Return a matrix (list of lists) representing a month's calendar.
198        Each row represents a week; week entries are datetime.date values.
199        """
200        dates = list(self.itermonthdates(year, month))
201        return [ dates[i:i+7] for i in range(0, len(dates), 7) ]
202
203    def monthdays2calendar(self, year, month):
204        """
205        Return a matrix representing a month's calendar.
206        Each row represents a week; week entries are
207        (day number, weekday number) tuples. Day numbers outside this month
208        are zero.
209        """
210        days = list(self.itermonthdays2(year, month))
211        return [ days[i:i+7] for i in range(0, len(days), 7) ]
212
213    def monthdayscalendar(self, year, month):
214        """
215        Return a matrix representing a month's calendar.
216        Each row represents a week; days outside this month are zero.
217        """
218        days = list(self.itermonthdays(year, month))
219        return [ days[i:i+7] for i in range(0, len(days), 7) ]
220
221    def yeardatescalendar(self, year, width=3):
222        """
223        Return the data for the specified year ready for formatting. The return
224        value is a list of month rows. Each month row contains up to width months.
225        Each month contains between 4 and 6 weeks and each week contains 1-7
226        days. Days are datetime.date objects.
227        """
228        months = [
229            self.monthdatescalendar(year, i)
230            for i in range(January, January+12)
231        ]
232        return [months[i:i+width] for i in range(0, len(months), width) ]
233
234    def yeardays2calendar(self, year, width=3):
235        """
236        Return the data for the specified year ready for formatting (similar to
237        yeardatescalendar()). Entries in the week lists are
238        (day number, weekday number) tuples. Day numbers outside this month are
239        zero.
240        """
241        months = [
242            self.monthdays2calendar(year, i)
243            for i in range(January, January+12)
244        ]
245        return [months[i:i+width] for i in range(0, len(months), width) ]
246
247    def yeardayscalendar(self, year, width=3):
248        """
249        Return the data for the specified year ready for formatting (similar to
250        yeardatescalendar()). Entries in the week lists are day numbers.
251        Day numbers outside this month are zero.
252        """
253        months = [
254            self.monthdayscalendar(year, i)
255            for i in range(January, January+12)
256        ]
257        return [months[i:i+width] for i in range(0, len(months), width) ]
258
259
260class TextCalendar(Calendar):
261    """
262    Subclass of Calendar that outputs a calendar as a simple plain text
263    similar to the UNIX program cal.
264    """
265
266    def prweek(self, theweek, width):
267        """
268        Print a single week (no newline).
269        """
270        print self.formatweek(theweek, width),
271
272    def formatday(self, day, weekday, width):
273        """
274        Returns a formatted day.
275        """
276        if day == 0:
277            s = ''
278        else:
279            s = '%2i' % day             # right-align single-digit days
280        return s.center(width)
281
282    def formatweek(self, theweek, width):
283        """
284        Returns a single week in a string (no newline).
285        """
286        return ' '.join(self.formatday(d, wd, width) for (d, wd) in theweek)
287
288    def formatweekday(self, day, width):
289        """
290        Returns a formatted week day name.
291        """
292        if width >= 9:
293            names = day_name
294        else:
295            names = day_abbr
296        return names[day][:width].center(width)
297
298    def formatweekheader(self, width):
299        """
300        Return a header for a week.
301        """
302        return ' '.join(self.formatweekday(i, width) for i in self.iterweekdays())
303
304    def formatmonthname(self, theyear, themonth, width, withyear=True):
305        """
306        Return a formatted month name.
307        """
308        s = month_name[themonth]
309        if withyear:
310            s = "%s %r" % (s, theyear)
311        return s.center(width)
312
313    def prmonth(self, theyear, themonth, w=0, l=0):
314        """
315        Print a month's calendar.
316        """
317        print self.formatmonth(theyear, themonth, w, l),
318
319    def formatmonth(self, theyear, themonth, w=0, l=0):
320        """
321        Return a month's calendar string (multi-line).
322        """
323        w = max(2, w)
324        l = max(1, l)
325        s = self.formatmonthname(theyear, themonth, 7 * (w + 1) - 1)
326        s = s.rstrip()
327        s += '\n' * l
328        s += self.formatweekheader(w).rstrip()
329        s += '\n' * l
330        for week in self.monthdays2calendar(theyear, themonth):
331            s += self.formatweek(week, w).rstrip()
332            s += '\n' * l
333        return s
334
335    def formatyear(self, theyear, w=2, l=1, c=6, m=3):
336        """
337        Returns a year's calendar as a multi-line string.
338        """
339        w = max(2, w)
340        l = max(1, l)
341        c = max(2, c)
342        colwidth = (w + 1) * 7 - 1
343        v = []
344        a = v.append
345        a(repr(theyear).center(colwidth*m+c*(m-1)).rstrip())
346        a('\n'*l)
347        header = self.formatweekheader(w)
348        for (i, row) in enumerate(self.yeardays2calendar(theyear, m)):
349            # months in this row
350            months = range(m*i+1, min(m*(i+1)+1, 13))
351            a('\n'*l)
352            names = (self.formatmonthname(theyear, k, colwidth, False)
353                     for k in months)
354            a(formatstring(names, colwidth, c).rstrip())
355            a('\n'*l)
356            headers = (header for k in months)
357            a(formatstring(headers, colwidth, c).rstrip())
358            a('\n'*l)
359            # max number of weeks for this row
360            height = max(len(cal) for cal in row)
361            for j in range(height):
362                weeks = []
363                for cal in row:
364                    if j >= len(cal):
365                        weeks.append('')
366                    else:
367                        weeks.append(self.formatweek(cal[j], w))
368                a(formatstring(weeks, colwidth, c).rstrip())
369                a('\n' * l)
370        return ''.join(v)
371
372    def pryear(self, theyear, w=0, l=0, c=6, m=3):
373        """Print a year's calendar."""
374        print self.formatyear(theyear, w, l, c, m)
375
376
377class HTMLCalendar(Calendar):
378    """
379    This calendar returns complete HTML pages.
380    """
381
382    # CSS classes for the day <td>s
383    cssclasses = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
384
385    def formatday(self, day, weekday):
386        """
387        Return a day as a table cell.
388        """
389        if day == 0:
390            return '<td class="noday">&nbsp;</td>' # day outside month
391        else:
392            return '<td class="%s">%d</td>' % (self.cssclasses[weekday], day)
393
394    def formatweek(self, theweek):
395        """
396        Return a complete week as a table row.
397        """
398        s = ''.join(self.formatday(d, wd) for (d, wd) in theweek)
399        return '<tr>%s</tr>' % s
400
401    def formatweekday(self, day):
402        """
403        Return a weekday name as a table header.
404        """
405        return '<th class="%s">%s</th>' % (self.cssclasses[day], day_abbr[day])
406
407    def formatweekheader(self):
408        """
409        Return a header for a week as a table row.
410        """
411        s = ''.join(self.formatweekday(i) for i in self.iterweekdays())
412        return '<tr>%s</tr>' % s
413
414    def formatmonthname(self, theyear, themonth, withyear=True):
415        """
416        Return a month name as a table row.
417        """
418        if withyear:
419            s = '%s %s' % (month_name[themonth], theyear)
420        else:
421            s = '%s' % month_name[themonth]
422        return '<tr><th colspan="7" class="month">%s</th></tr>' % s
423
424    def formatmonth(self, theyear, themonth, withyear=True):
425        """
426        Return a formatted month as a table.
427        """
428        v = []
429        a = v.append
430        a('<table border="0" cellpadding="0" cellspacing="0" class="month">')
431        a('\n')
432        a(self.formatmonthname(theyear, themonth, withyear=withyear))
433        a('\n')
434        a(self.formatweekheader())
435        a('\n')
436        for week in self.monthdays2calendar(theyear, themonth):
437            a(self.formatweek(week))
438            a('\n')
439        a('</table>')
440        a('\n')
441        return ''.join(v)
442
443    def formatyear(self, theyear, width=3):
444        """
445        Return a formatted year as a table of tables.
446        """
447        v = []
448        a = v.append
449        width = max(width, 1)
450        a('<table border="0" cellpadding="0" cellspacing="0" class="year">')
451        a('\n')
452        a('<tr><th colspan="%d" class="year">%s</th></tr>' % (width, theyear))
453        for i in range(January, January+12, width):
454            # months in this row
455            months = range(i, min(i+width, 13))
456            a('<tr>')
457            for m in months:
458                a('<td>')
459                a(self.formatmonth(theyear, m, withyear=False))
460                a('</td>')
461            a('</tr>')
462        a('</table>')
463        return ''.join(v)
464
465    def formatyearpage(self, theyear, width=3, css='calendar.css', encoding=None):
466        """
467        Return a formatted year as a complete HTML page.
468        """
469        if encoding is None:
470            encoding = sys.getdefaultencoding()
471        v = []
472        a = v.append
473        a('<?xml version="1.0" encoding="%s"?>\n' % encoding)
474        a('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n')
475        a('<html>\n')
476        a('<head>\n')
477        a('<meta http-equiv="Content-Type" content="text/html; charset=%s" />\n' % encoding)
478        if css is not None:
479            a('<link rel="stylesheet" type="text/css" href="%s" />\n' % css)
480        a('<title>Calendar for %d</title>\n' % theyear)
481        a('</head>\n')
482        a('<body>\n')
483        a(self.formatyear(theyear, width))
484        a('</body>\n')
485        a('</html>\n')
486        return ''.join(v).encode(encoding, "xmlcharrefreplace")
487
488
489class TimeEncoding:
490    def __init__(self, locale):
491        self.locale = locale
492
493    def __enter__(self):
494        self.oldlocale = _locale.getlocale(_locale.LC_TIME)
495        _locale.setlocale(_locale.LC_TIME, self.locale)
496        return _locale.getlocale(_locale.LC_TIME)[1]
497
498    def __exit__(self, *args):
499        _locale.setlocale(_locale.LC_TIME, self.oldlocale)
500
501
502class LocaleTextCalendar(TextCalendar):
503    """
504    This class can be passed a locale name in the constructor and will return
505    month and weekday names in the specified locale. If this locale includes
506    an encoding all strings containing month and weekday names will be returned
507    as unicode.
508    """
509
510    def __init__(self, firstweekday=0, locale=None):
511        TextCalendar.__init__(self, firstweekday)
512        if locale is None:
513            locale = _locale.getdefaultlocale()
514        self.locale = locale
515
516    def formatweekday(self, day, width):
517        with TimeEncoding(self.locale) as encoding:
518            if width >= 9:
519                names = day_name
520            else:
521                names = day_abbr
522            name = names[day]
523            if encoding is not None:
524                name = name.decode(encoding)
525            return name[:width].center(width)
526
527    def formatmonthname(self, theyear, themonth, width, withyear=True):
528        with TimeEncoding(self.locale) as encoding:
529            s = month_name[themonth]
530            if encoding is not None:
531                s = s.decode(encoding)
532            if withyear:
533                s = "%s %r" % (s, theyear)
534            return s.center(width)
535
536
537class LocaleHTMLCalendar(HTMLCalendar):
538    """
539    This class can be passed a locale name in the constructor and will return
540    month and weekday names in the specified locale. If this locale includes
541    an encoding all strings containing month and weekday names will be returned
542    as unicode.
543    """
544    def __init__(self, firstweekday=0, locale=None):
545        HTMLCalendar.__init__(self, firstweekday)
546        if locale is None:
547            locale = _locale.getdefaultlocale()
548        self.locale = locale
549
550    def formatweekday(self, day):
551        with TimeEncoding(self.locale) as encoding:
552            s = day_abbr[day]
553            if encoding is not None:
554                s = s.decode(encoding)
555            return '<th class="%s">%s</th>' % (self.cssclasses[day], s)
556
557    def formatmonthname(self, theyear, themonth, withyear=True):
558        with TimeEncoding(self.locale) as encoding:
559            s = month_name[themonth]
560            if encoding is not None:
561                s = s.decode(encoding)
562            if withyear:
563                s = '%s %s' % (s, theyear)
564            return '<tr><th colspan="7" class="month">%s</th></tr>' % s
565
566
567# Support for old module level interface
568c = TextCalendar()
569
570firstweekday = c.getfirstweekday
571
572def setfirstweekday(firstweekday):
573    try:
574        firstweekday.__index__
575    except AttributeError:
576        raise IllegalWeekdayError(firstweekday)
577    if not MONDAY <= firstweekday <= SUNDAY:
578        raise IllegalWeekdayError(firstweekday)
579    c.firstweekday = firstweekday
580
581monthcalendar = c.monthdayscalendar
582prweek = c.prweek
583week = c.formatweek
584weekheader = c.formatweekheader
585prmonth = c.prmonth
586month = c.formatmonth
587calendar = c.formatyear
588prcal = c.pryear
589
590
591# Spacing of month columns for multi-column year calendar
592_colwidth = 7*3 - 1         # Amount printed by prweek()
593_spacing = 6                # Number of spaces between columns
594
595
596def format(cols, colwidth=_colwidth, spacing=_spacing):
597    """Prints multi-column formatting for year calendars"""
598    print formatstring(cols, colwidth, spacing)
599
600
601def formatstring(cols, colwidth=_colwidth, spacing=_spacing):
602    """Returns a string formatted from n strings, centered within n columns."""
603    spacing *= ' '
604    return spacing.join(c.center(colwidth) for c in cols)
605
606
607EPOCH = 1970
608_EPOCH_ORD = datetime.date(EPOCH, 1, 1).toordinal()
609
610
611def timegm(tuple):
612    """Unrelated but handy function to calculate Unix timestamp from GMT."""
613    year, month, day, hour, minute, second = tuple[:6]
614    days = datetime.date(year, month, 1).toordinal() - _EPOCH_ORD + day - 1
615    hours = days*24 + hour
616    minutes = hours*60 + minute
617    seconds = minutes*60 + second
618    return seconds
619
620
621def main(args):
622    import optparse
623    parser = optparse.OptionParser(usage="usage: %prog [options] [year [month]]")
624    parser.add_option(
625        "-w", "--width",
626        dest="width", type="int", default=2,
627        help="width of date column (default 2, text only)"
628    )
629    parser.add_option(
630        "-l", "--lines",
631        dest="lines", type="int", default=1,
632        help="number of lines for each week (default 1, text only)"
633    )
634    parser.add_option(
635        "-s", "--spacing",
636        dest="spacing", type="int", default=6,
637        help="spacing between months (default 6, text only)"
638    )
639    parser.add_option(
640        "-m", "--months",
641        dest="months", type="int", default=3,
642        help="months per row (default 3, text only)"
643    )
644    parser.add_option(
645        "-c", "--css",
646        dest="css", default="calendar.css",
647        help="CSS to use for page (html only)"
648    )
649    parser.add_option(
650        "-L", "--locale",
651        dest="locale", default=None,
652        help="locale to be used from month and weekday names"
653    )
654    parser.add_option(
655        "-e", "--encoding",
656        dest="encoding", default=None,
657        help="Encoding to use for output"
658    )
659    parser.add_option(
660        "-t", "--type",
661        dest="type", default="text",
662        choices=("text", "html"),
663        help="output type (text or html)"
664    )
665
666    (options, args) = parser.parse_args(args)
667
668    if options.locale and not options.encoding:
669        parser.error("if --locale is specified --encoding is required")
670        sys.exit(1)
671
672    locale = options.locale, options.encoding
673
674    if options.type == "html":
675        if options.locale:
676            cal = LocaleHTMLCalendar(locale=locale)
677        else:
678            cal = HTMLCalendar()
679        encoding = options.encoding
680        if encoding is None:
681            encoding = sys.getdefaultencoding()
682        optdict = dict(encoding=encoding, css=options.css)
683        if len(args) == 1:
684            print cal.formatyearpage(datetime.date.today().year, **optdict)
685        elif len(args) == 2:
686            print cal.formatyearpage(int(args[1]), **optdict)
687        else:
688            parser.error("incorrect number of arguments")
689            sys.exit(1)
690    else:
691        if options.locale:
692            cal = LocaleTextCalendar(locale=locale)
693        else:
694            cal = TextCalendar()
695        optdict = dict(w=options.width, l=options.lines)
696        if len(args) != 3:
697            optdict["c"] = options.spacing
698            optdict["m"] = options.months
699        if len(args) == 1:
700            result = cal.formatyear(datetime.date.today().year, **optdict)
701        elif len(args) == 2:
702            result = cal.formatyear(int(args[1]), **optdict)
703        elif len(args) == 3:
704            result = cal.formatmonth(int(args[1]), int(args[2]), **optdict)
705        else:
706            parser.error("incorrect number of arguments")
707            sys.exit(1)
708        if options.encoding:
709            result = result.encode(options.encoding)
710        print result
711
712
713if __name__ == "__main__":
714    main(sys.argv)
715