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