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 from 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) 21 error = ValueError 22 23 # Exceptions raised for bad input 24 class 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 31 class 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 39 January = 1 40 February = 2 41 42 # Number of days per month (except for February in leap years) 43 mdays = [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 50 class _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 69 class _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 89 day_name = _localized_day('%A') 90 day_abbr = _localized_day('%a') 91 92 # Full and abbreviated names of months (1-based arrays!!!) 93 month_name = _localized_month('%B') 94 month_abbr = _localized_month('%b') 95 96 # Constants for weekdays 97 (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7) 98 99 100 def 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 105 def 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 113 def weekday(year, month, day): 114 """Return weekday (0-6 ~ Mon-Sun) for year (1970-...), month (1-12), 115 day (1-31).""" 116 return datetime.date(year, month, day).weekday() 117 118 119 def monthrange(year, month): 120 """Return weekday (0-6 ~ Mon-Sun) and number of days (28-31) for 121 year, month.""" 122 if not 1 <= month <= 12: 123 raise IllegalMonthError(month) 124 day1 = weekday(year, month, 1) 125 ndays = mdays[month] + (month == February and isleap(year)) 126 return day1, ndays 127 128 129 class Calendar(object): 130 """ 131 Base calendar class. This class doesn't do any formatting. It simply 132 provides data to subclasses. 133 """ 134 135 def __init__(self, firstweekday=0): 136 self.firstweekday = firstweekday # 0 = Monday, 6 = Sunday 137 138 def getfirstweekday(self): 139 return self._firstweekday % 7 140 141 def setfirstweekday(self, firstweekday): 142 self._firstweekday = firstweekday 143 144 firstweekday = property(getfirstweekday, setfirstweekday) 145 146 def iterweekdays(self): 147 """ 148 Return an iterator for one week of weekday numbers starting with the 149 configured first one. 150 """ 151 for i in range(self.firstweekday, self.firstweekday + 7): 152 yield i%7 153 154 def itermonthdates(self, year, month): 155 """ 156 Return an iterator for one month. The iterator will yield datetime.date 157 values and will always iterate through complete weeks, so it will yield 158 dates outside the specified month. 159 """ 160 date = datetime.date(year, month, 1) 161 # Go back to the beginning of the week 162 days = (date.weekday() - self.firstweekday) % 7 163 date -= datetime.timedelta(days=days) 164 oneday = datetime.timedelta(days=1) 165 while True: 166 yield date 167 try: 168 date += oneday 169 except OverflowError: 170 # Adding one day could fail after datetime.MAXYEAR 171 break 172 if date.month != month and date.weekday() == self.firstweekday: 173 break 174 175 def itermonthdays2(self, year, month): 176 """ 177 Like itermonthdates(), but will yield (day number, weekday number) 178 tuples. For days outside the specified month the day number is 0. 179 """ 180 for i, d in enumerate(self.itermonthdays(year, month), self.firstweekday): 181 yield d, i % 7 182 183 def itermonthdays(self, year, month): 184 """ 185 Like itermonthdates(), but will yield day numbers. For days outside 186 the specified month the day number is 0. 187 """ 188 day1, ndays = monthrange(year, month) 189 days_before = (day1 - self.firstweekday) % 7 190 yield from repeat(0, days_before) 191 yield from range(1, ndays + 1) 192 days_after = (self.firstweekday - day1 - ndays) % 7 193 yield from repeat(0, days_after) 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 260 class 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), end=' ') 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), end='') 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 377 class 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 489 class different_locale: 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 497 def __exit__(self, *args): 498 _locale.setlocale(_locale.LC_TIME, self.oldlocale) 499 500 501 class LocaleTextCalendar(TextCalendar): 502 """ 503 This class can be passed a locale name in the constructor and will return 504 month and weekday names in the specified locale. If this locale includes 505 an encoding all strings containing month and weekday names will be returned 506 as unicode. 507 """ 508 509 def __init__(self, firstweekday=0, locale=None): 510 TextCalendar.__init__(self, firstweekday) 511 if locale is None: 512 locale = _locale.getdefaultlocale() 513 self.locale = locale 514 515 def formatweekday(self, day, width): 516 with different_locale(self.locale): 517 if width >= 9: 518 names = day_name 519 else: 520 names = day_abbr 521 name = names[day] 522 return name[:width].center(width) 523 524 def formatmonthname(self, theyear, themonth, width, withyear=True): 525 with different_locale(self.locale): 526 s = month_name[themonth] 527 if withyear: 528 s = "%s %r" % (s, theyear) 529 return s.center(width) 530 531 532 class LocaleHTMLCalendar(HTMLCalendar): 533 """ 534 This class can be passed a locale name in the constructor and will return 535 month and weekday names in the specified locale. If this locale includes 536 an encoding all strings containing month and weekday names will be returned 537 as unicode. 538 """ 539 def __init__(self, firstweekday=0, locale=None): 540 HTMLCalendar.__init__(self, firstweekday) 541 if locale is None: 542 locale = _locale.getdefaultlocale() 543 self.locale = locale 544 545 def formatweekday(self, day): 546 with different_locale(self.locale): 547 s = day_abbr[day] 548 return '<th class="%s">%s</th>' % (self.cssclasses[day], s) 549 550 def formatmonthname(self, theyear, themonth, withyear=True): 551 with different_locale(self.locale): 552 s = month_name[themonth] 553 if withyear: 554 s = '%s %s' % (s, theyear) 555 return '<tr><th colspan="7" class="month">%s</th></tr>' % s 556 557 558 # Support for old module level interface 559 c = TextCalendar() 560 561 firstweekday = c.getfirstweekday 562 563 def setfirstweekday(firstweekday): 564 if not MONDAY <= firstweekday <= SUNDAY: 565 raise IllegalWeekdayError(firstweekday) 566 c.firstweekday = firstweekday 567 568 monthcalendar = c.monthdayscalendar 569 prweek = c.prweek 570 week = c.formatweek 571 weekheader = c.formatweekheader 572 prmonth = c.prmonth 573 month = c.formatmonth 574 calendar = c.formatyear 575 prcal = c.pryear 576 577 578 # Spacing of month columns for multi-column year calendar 579 _colwidth = 7*3 - 1 # Amount printed by prweek() 580 _spacing = 6 # Number of spaces between columns 581 582 583 def format(cols, colwidth=_colwidth, spacing=_spacing): 584 """Prints multi-column formatting for year calendars""" 585 print(formatstring(cols, colwidth, spacing)) 586 587 588 def formatstring(cols, colwidth=_colwidth, spacing=_spacing): 589 """Returns a string formatted from n strings, centered within n columns.""" 590 spacing *= ' ' 591 return spacing.join(c.center(colwidth) for c in cols) 592 593 594 EPOCH = 1970 595 _EPOCH_ORD = datetime.date(EPOCH, 1, 1).toordinal() 596 597 598 def timegm(tuple): 599 """Unrelated but handy function to calculate Unix timestamp from GMT.""" 600 year, month, day, hour, minute, second = tuple[:6] 601 days = datetime.date(year, month, 1).toordinal() - _EPOCH_ORD + day - 1 602 hours = days*24 + hour 603 minutes = hours*60 + minute 604 seconds = minutes*60 + second 605 return seconds 606 607 608 def main(args): 609 import argparse 610 parser = argparse.ArgumentParser() 611 textgroup = parser.add_argument_group('text only arguments') 612 htmlgroup = parser.add_argument_group('html only arguments') 613 textgroup.add_argument( 614 "-w", "--width", 615 type=int, default=2, 616 help="width of date column (default 2)" 617 ) 618 textgroup.add_argument( 619 "-l", "--lines", 620 type=int, default=1, 621 help="number of lines for each week (default 1)" 622 ) 623 textgroup.add_argument( 624 "-s", "--spacing", 625 type=int, default=6, 626 help="spacing between months (default 6)" 627 ) 628 textgroup.add_argument( 629 "-m", "--months", 630 type=int, default=3, 631 help="months per row (default 3)" 632 ) 633 htmlgroup.add_argument( 634 "-c", "--css", 635 default="calendar.css", 636 help="CSS to use for page" 637 ) 638 parser.add_argument( 639 "-L", "--locale", 640 default=None, 641 help="locale to be used from month and weekday names" 642 ) 643 parser.add_argument( 644 "-e", "--encoding", 645 default=None, 646 help="encoding to use for output" 647 ) 648 parser.add_argument( 649 "-t", "--type", 650 default="text", 651 choices=("text", "html"), 652 help="output type (text or html)" 653 ) 654 parser.add_argument( 655 "year", 656 nargs='?', type=int, 657 help="year number (1-9999)" 658 ) 659 parser.add_argument( 660 "month", 661 nargs='?', type=int, 662 help="month number (1-12, text only)" 663 ) 664 665 options = parser.parse_args(args[1:]) 666 667 if options.locale and not options.encoding: 668 parser.error("if --locale is specified --encoding is required") 669 sys.exit(1) 670 671 locale = options.locale, options.encoding 672 673 if options.type == "html": 674 if options.locale: 675 cal = LocaleHTMLCalendar(locale=locale) 676 else: 677 cal = HTMLCalendar() 678 encoding = options.encoding 679 if encoding is None: 680 encoding = sys.getdefaultencoding() 681 optdict = dict(encoding=encoding, css=options.css) 682 write = sys.stdout.buffer.write 683 if options.year is None: 684 write(cal.formatyearpage(datetime.date.today().year, **optdict)) 685 elif options.month is None: 686 write(cal.formatyearpage(options.year, **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 options.month is None: 697 optdict["c"] = options.spacing 698 optdict["m"] = options.months 699 if options.year is None: 700 result = cal.formatyear(datetime.date.today().year, **optdict) 701 elif options.month is None: 702 result = cal.formatyear(options.year, **optdict) 703 else: 704 result = cal.formatmonth(options.year, options.month, **optdict) 705 write = sys.stdout.write 706 if options.encoding: 707 result = result.encode(options.encoding) 708 write = sys.stdout.buffer.write 709 write(result) 710 711 712 if __name__ == "__main__": 713 main(sys.argv) 714