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