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