• 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            return super().formatweekday(day, width)
575
576    def formatmonthname(self, theyear, themonth, width, withyear=True):
577        with different_locale(self.locale):
578            return super().formatmonthname(theyear, themonth, width, withyear)
579
580
581class LocaleHTMLCalendar(HTMLCalendar):
582    """
583    This class can be passed a locale name in the constructor and will return
584    month and weekday names in the specified locale. If this locale includes
585    an encoding all strings containing month and weekday names will be returned
586    as unicode.
587    """
588    def __init__(self, firstweekday=0, locale=None):
589        HTMLCalendar.__init__(self, firstweekday)
590        if locale is None:
591            locale = _locale.getdefaultlocale()
592        self.locale = locale
593
594    def formatweekday(self, day):
595        with different_locale(self.locale):
596            return super().formatweekday(day)
597
598    def formatmonthname(self, theyear, themonth, withyear=True):
599        with different_locale(self.locale):
600            return super().formatmonthname(theyear, themonth, withyear)
601
602# Support for old module level interface
603c = TextCalendar()
604
605firstweekday = c.getfirstweekday
606
607def setfirstweekday(firstweekday):
608    if not MONDAY <= firstweekday <= SUNDAY:
609        raise IllegalWeekdayError(firstweekday)
610    c.firstweekday = firstweekday
611
612monthcalendar = c.monthdayscalendar
613prweek = c.prweek
614week = c.formatweek
615weekheader = c.formatweekheader
616prmonth = c.prmonth
617month = c.formatmonth
618calendar = c.formatyear
619prcal = c.pryear
620
621
622# Spacing of month columns for multi-column year calendar
623_colwidth = 7*3 - 1         # Amount printed by prweek()
624_spacing = 6                # Number of spaces between columns
625
626
627def format(cols, colwidth=_colwidth, spacing=_spacing):
628    """Prints multi-column formatting for year calendars"""
629    print(formatstring(cols, colwidth, spacing))
630
631
632def formatstring(cols, colwidth=_colwidth, spacing=_spacing):
633    """Returns a string formatted from n strings, centered within n columns."""
634    spacing *= ' '
635    return spacing.join(c.center(colwidth) for c in cols)
636
637
638EPOCH = 1970
639_EPOCH_ORD = datetime.date(EPOCH, 1, 1).toordinal()
640
641
642def timegm(tuple):
643    """Unrelated but handy function to calculate Unix timestamp from GMT."""
644    year, month, day, hour, minute, second = tuple[:6]
645    days = datetime.date(year, month, 1).toordinal() - _EPOCH_ORD + day - 1
646    hours = days*24 + hour
647    minutes = hours*60 + minute
648    seconds = minutes*60 + second
649    return seconds
650
651
652def main(args):
653    import argparse
654    parser = argparse.ArgumentParser()
655    textgroup = parser.add_argument_group('text only arguments')
656    htmlgroup = parser.add_argument_group('html only arguments')
657    textgroup.add_argument(
658        "-w", "--width",
659        type=int, default=2,
660        help="width of date column (default 2)"
661    )
662    textgroup.add_argument(
663        "-l", "--lines",
664        type=int, default=1,
665        help="number of lines for each week (default 1)"
666    )
667    textgroup.add_argument(
668        "-s", "--spacing",
669        type=int, default=6,
670        help="spacing between months (default 6)"
671    )
672    textgroup.add_argument(
673        "-m", "--months",
674        type=int, default=3,
675        help="months per row (default 3)"
676    )
677    htmlgroup.add_argument(
678        "-c", "--css",
679        default="calendar.css",
680        help="CSS to use for page"
681    )
682    parser.add_argument(
683        "-L", "--locale",
684        default=None,
685        help="locale to be used from month and weekday names"
686    )
687    parser.add_argument(
688        "-e", "--encoding",
689        default=None,
690        help="encoding to use for output"
691    )
692    parser.add_argument(
693        "-t", "--type",
694        default="text",
695        choices=("text", "html"),
696        help="output type (text or html)"
697    )
698    parser.add_argument(
699        "year",
700        nargs='?', type=int,
701        help="year number (1-9999)"
702    )
703    parser.add_argument(
704        "month",
705        nargs='?', type=int,
706        help="month number (1-12, text only)"
707    )
708
709    options = parser.parse_args(args[1:])
710
711    if options.locale and not options.encoding:
712        parser.error("if --locale is specified --encoding is required")
713        sys.exit(1)
714
715    locale = options.locale, options.encoding
716
717    if options.type == "html":
718        if options.locale:
719            cal = LocaleHTMLCalendar(locale=locale)
720        else:
721            cal = HTMLCalendar()
722        encoding = options.encoding
723        if encoding is None:
724            encoding = sys.getdefaultencoding()
725        optdict = dict(encoding=encoding, css=options.css)
726        write = sys.stdout.buffer.write
727        if options.year is None:
728            write(cal.formatyearpage(datetime.date.today().year, **optdict))
729        elif options.month is None:
730            write(cal.formatyearpage(options.year, **optdict))
731        else:
732            parser.error("incorrect number of arguments")
733            sys.exit(1)
734    else:
735        if options.locale:
736            cal = LocaleTextCalendar(locale=locale)
737        else:
738            cal = TextCalendar()
739        optdict = dict(w=options.width, l=options.lines)
740        if options.month is None:
741            optdict["c"] = options.spacing
742            optdict["m"] = options.months
743        if options.year is None:
744            result = cal.formatyear(datetime.date.today().year, **optdict)
745        elif options.month is None:
746            result = cal.formatyear(options.year, **optdict)
747        else:
748            result = cal.formatmonth(options.year, options.month, **optdict)
749        write = sys.stdout.write
750        if options.encoding:
751            result = result.encode(options.encoding)
752            write = sys.stdout.buffer.write
753        write(result)
754
755
756if __name__ == "__main__":
757    main(sys.argv)
758