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