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"> </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