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