1 #!/usr/bin/env python 2 3 """ 4 Date processing functions. 5 6 Copyright (C) 2014, 2015, 2016 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from bisect import bisect_left 23 from datetime import date, datetime, timedelta 24 from os.path import exists 25 from pytz import timezone, UnknownTimeZoneError 26 import re 27 28 # iCalendar date and datetime parsing (from DateSupport in MoinSupport). 29 30 _date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 31 date_icalendar_regexp_str = _date_icalendar_regexp_str + '$' 32 33 datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \ 34 ur'(?:' \ 35 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 36 ur'(?P<utc>Z)?' \ 37 ur')?$' 38 39 _duration_time_icalendar_regexp_str = \ 40 ur'T' \ 41 ur'(?:' \ 42 ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \ 43 ur'|' \ 44 ur'([0-9]+M)([0-9]+S)?' \ 45 ur'|' \ 46 ur'([0-9]+S)' \ 47 ur')' 48 49 duration_icalendar_regexp_str = ur'P' \ 50 ur'(?:' \ 51 ur'([0-9]+W)' \ 52 ur'|' \ 53 ur'(?:%s)' \ 54 ur'|' \ 55 ur'([0-9]+D)(?:%s)?' \ 56 ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str) 57 58 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match 59 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match 60 match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match 61 62 # Datetime formatting. 63 64 def format_datetime(dt): 65 66 "Format 'dt' as an iCalendar-compatible string." 67 68 if not dt: 69 return None 70 elif isinstance(dt, datetime): 71 if dt.tzname() == "UTC": 72 return dt.strftime("%Y%m%dT%H%M%SZ") 73 else: 74 return dt.strftime("%Y%m%dT%H%M%S") 75 else: 76 return dt.strftime("%Y%m%d") 77 78 def format_time(dt): 79 80 "Format the time portion of 'dt' as an iCalendar-compatible string." 81 82 if not dt: 83 return None 84 elif isinstance(dt, datetime): 85 if dt.tzname() == "UTC": 86 return dt.strftime("%H%M%SZ") 87 else: 88 return dt.strftime("%H%M%S") 89 else: 90 return None 91 92 def format_duration(td): 93 94 "Format the timedelta 'td' as an iCalendar-compatible string." 95 96 if not td: 97 return None 98 else: 99 day_portion = td.days and "%dD" % td.days or "" 100 time_portion = td.seconds and "T%dS" % td.seconds or "" 101 if not day_portion and not time_portion: 102 time_portion = "T0S" 103 return "P%s%s" % (day_portion, time_portion) 104 105 # Parsing of datetime and related information. 106 107 def get_datetime(value, attr=None): 108 109 """ 110 Return a datetime object from the given 'value' in iCalendar format, using 111 the 'attr' mapping (if specified) to control the conversion. 112 """ 113 114 if not value: 115 return None 116 117 if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")): 118 m = match_datetime_icalendar(value) 119 if m: 120 year, month, day, hour, minute, second = map(m.group, [ 121 "year", "month", "day", "hour", "minute", "second" 122 ]) 123 124 if hour and minute and second: 125 dt = datetime( 126 int(year), int(month), int(day), int(hour), int(minute), int(second) 127 ) 128 129 # Impose the indicated timezone. 130 # NOTE: This needs an ambiguity policy for DST changes. 131 132 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 133 134 return None 135 136 # Permit dates even if the VALUE is not set to DATE. 137 138 if not attr or attr.get("VALUE") in (None, "DATE"): 139 m = match_date_icalendar(value) 140 if m: 141 year, month, day = map(m.group, ["year", "month", "day"]) 142 return date(int(year), int(month), int(day)) 143 144 return None 145 146 def get_duration(value): 147 148 """ 149 Return a duration for the given 'value' as a timedelta object. 150 Where no valid duration is specified, None is returned. 151 """ 152 153 if not value: 154 return None 155 156 m = match_duration_icalendar(value) 157 if m: 158 weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0 159 for s in m.groups(): 160 if not s: continue 161 if s[-1] == "W": weeks += int(s[:-1]) 162 elif s[-1] == "D": days += int(s[:-1]) 163 elif s[-1] == "H": hours += int(s[:-1]) 164 elif s[-1] == "M": minutes += int(s[:-1]) 165 elif s[-1] == "S": seconds += int(s[:-1]) 166 return timedelta( 167 int(weeks) * 7 + int(days), 168 (int(hours) * 60 + int(minutes)) * 60 + int(seconds) 169 ) 170 else: 171 return None 172 173 def get_period(value, attr=None): 174 175 """ 176 Return a tuple of the form (start, end) for the given 'value' in iCalendar 177 format, using the 'attr' mapping (if specified) to control the conversion. 178 """ 179 180 if not value or attr and attr.get("VALUE") and attr.get("VALUE") != "PERIOD": 181 return None 182 183 t = value.split("/") 184 if len(t) != 2: 185 return None 186 187 dtattr = {} 188 if attr: 189 dtattr.update(attr) 190 if dtattr.has_key("VALUE"): 191 del dtattr["VALUE"] 192 193 start = get_datetime(t[0], dtattr) 194 if t[1].startswith("P"): 195 end = start + get_duration(t[1]) 196 else: 197 end = get_datetime(t[1], dtattr) 198 199 return start, end 200 201 # Time zone conversions and retrieval. 202 203 def ends_on_same_day(dt, end, tzid): 204 205 """ 206 Return whether 'dt' ends on the same day as 'end', testing the date 207 components of 'dt' and 'end' against each other, but also testing whether 208 'end' is the actual end of the day in which 'dt' is positioned. 209 210 Since time zone transitions may occur within a day, 'tzid' is required to 211 determine the end of the day in which 'dt' is positioned, using the zone 212 appropriate at that point in time, not necessarily the zone applying to 213 'dt'. 214 """ 215 216 return ( 217 to_timezone(dt, tzid).date() == to_timezone(end, tzid).date() or 218 end == get_end_of_day(dt, tzid) 219 ) 220 221 def get_default_timezone(): 222 223 "Return the system time regime." 224 225 filename = "/etc/timezone" 226 227 if exists(filename): 228 f = open(filename) 229 try: 230 return f.read().strip() 231 finally: 232 f.close() 233 else: 234 return None 235 236 def get_end_of_day(dt, tzid): 237 238 """ 239 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 240 to obtain a datetime in the appropriate time zone. Where time zone 241 transitions occur within a day, the zone of 'dt' may not be the eventual 242 zone of the returned object. 243 """ 244 245 return get_start_of_day(dt + timedelta(1), tzid) 246 247 def get_start_of_day(dt, tzid): 248 249 """ 250 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 251 to obtain a datetime in the appropriate time zone. Where time zone 252 transitions occur within a day, the zone of 'dt' may not be the eventual 253 zone of the returned object. 254 """ 255 256 start = datetime(dt.year, dt.month, dt.day, 0, 0) 257 return to_timezone(start, tzid) 258 259 def get_start_of_next_day(dt, tzid): 260 261 """ 262 Get the start of the day after the day in which 'dt' is positioned. This 263 function is intended to extend either dates or datetimes to the end of a 264 day for the purpose of generating a missing end date or datetime for an 265 event. 266 267 If 'dt' is a date and not a datetime, a plain date object for the next day 268 will be returned. 269 270 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 271 appropriate time zone. Where time zone transitions occur within a day, the 272 zone of 'dt' may not be the eventual zone of the returned object. 273 """ 274 275 if isinstance(dt, datetime): 276 return get_end_of_day(dt, tzid) 277 else: 278 return dt + timedelta(1) 279 280 def get_datetime_tzid(dt): 281 282 "Return the time zone identifier from 'dt' or None if unknown." 283 284 if not isinstance(dt, datetime): 285 return None 286 elif dt.tzname() == "UTC": 287 return "UTC" 288 elif dt.tzinfo and hasattr(dt.tzinfo, "zone"): 289 return dt.tzinfo.zone 290 else: 291 return None 292 293 def get_period_tzid(start, end): 294 295 "Return the time zone identifier for 'start' and 'end' or None if unknown." 296 297 if isinstance(start, datetime) or isinstance(end, datetime): 298 return get_datetime_tzid(start) or get_datetime_tzid(end) 299 else: 300 return None 301 302 def to_date(dt): 303 304 "Return the date of 'dt'." 305 306 return date(dt.year, dt.month, dt.day) 307 308 def to_datetime(dt, tzid): 309 310 """ 311 Return a datetime for 'dt', using the start of day for dates, and using the 312 'tzid' for the conversion. 313 """ 314 315 if isinstance(dt, datetime): 316 return to_timezone(dt, tzid) 317 else: 318 return get_start_of_day(dt, tzid) 319 320 def to_utc_datetime(dt, tzid=None): 321 322 """ 323 Return a datetime corresponding to 'dt' in the UTC time zone. If 'tzid' 324 is specified, dates and floating datetimes are converted to UTC datetimes 325 using the time zone information; otherwise, such dates and datetimes remain 326 unconverted. 327 """ 328 329 if not dt: 330 return None 331 elif get_datetime_tzid(dt): 332 return to_timezone(dt, "UTC") 333 elif tzid: 334 return to_timezone(to_datetime(dt, tzid), "UTC") 335 else: 336 return dt 337 338 def to_timezone(dt, tzid): 339 340 """ 341 Return a datetime corresponding to 'dt' in the time regime having the given 342 'tzid'. 343 """ 344 345 try: 346 tz = tzid and timezone(tzid) or None 347 except UnknownTimeZoneError: 348 tz = None 349 return to_tz(dt, tz) 350 351 def to_tz(dt, tz): 352 353 "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'." 354 355 if tz is not None and isinstance(dt, datetime): 356 if not dt.tzinfo: 357 return tz.localize(dt) 358 else: 359 return dt.astimezone(tz) 360 else: 361 return dt 362 363 # iCalendar-related conversions. 364 365 def end_date_from_calendar(dt): 366 367 """ 368 Change end dates to refer to the actual dates, not the iCalendar "next day" 369 dates. 370 """ 371 372 if not isinstance(dt, datetime): 373 return dt - timedelta(1) 374 else: 375 return dt 376 377 def end_date_to_calendar(dt): 378 379 """ 380 Change end dates to refer to the iCalendar "next day" dates, not the actual 381 dates. 382 """ 383 384 if not isinstance(dt, datetime): 385 return dt + timedelta(1) 386 else: 387 return dt 388 389 def get_datetime_attributes(dt, tzid=None): 390 391 """ 392 Return attributes for the 'dt' date or datetime object with 'tzid' 393 indicating the time zone if not otherwise defined. 394 """ 395 396 if isinstance(dt, datetime): 397 attr = {"VALUE" : "DATE-TIME"} 398 tzid = get_datetime_tzid(dt) or tzid 399 if tzid: 400 attr["TZID"] = tzid 401 return attr 402 else: 403 return {"VALUE" : "DATE"} 404 405 def get_datetime_item(dt, tzid=None): 406 407 """ 408 Return an iCalendar-compatible string and attributes for 'dt' using any 409 specified 'tzid' to assert a particular time zone if not otherwise defined. 410 """ 411 412 if not dt: 413 return None, None 414 if not get_datetime_tzid(dt): 415 dt = to_timezone(dt, tzid) 416 value = format_datetime(dt) 417 attr = get_datetime_attributes(dt, tzid) 418 return value, attr 419 420 def get_period_attributes(start, end, tzid=None): 421 422 """ 423 Return attributes for the 'start' and 'end' datetime objects with 'tzid' 424 indicating the time zone if not otherwise defined. 425 """ 426 427 attr = {"VALUE" : "PERIOD"} 428 tzid = get_period_tzid(start, end) or tzid 429 if tzid: 430 attr["TZID"] = tzid 431 return attr 432 433 def get_period_item(start, end, tzid=None): 434 435 """ 436 Return an iCalendar-compatible string and attributes for 'start', 'end' and 437 'tzid'. 438 """ 439 440 if start and end: 441 attr = get_period_attributes(start, end, tzid) 442 start_value = format_datetime(to_timezone(start, attr.get("TZID"))) 443 end_value = format_datetime(to_timezone(end, attr.get("TZID"))) 444 return "%s/%s" % (start_value, end_value), attr 445 elif start: 446 attr = get_datetime_attributes(start, tzid) 447 start_value = format_datetime(to_timezone(start, attr.get("TZID"))) 448 return start_value, attr 449 else: 450 return None, None 451 452 def get_timestamp(offset=None): 453 454 "Return the current time as an iCalendar-compatible string." 455 456 offset = offset or timedelta(0) 457 return format_datetime(to_timezone(datetime.utcnow(), "UTC") + offset) 458 459 def get_date(offset=None): 460 461 """ 462 Return the current date, offset by the given timedelta 'offset' if 463 specified. The returned date will not be positioned in any time zone. 464 """ 465 466 offset = offset or timedelta(0) 467 return date.today() + offset 468 469 def get_time(offset=None): 470 471 """ 472 Return the current time, offset by the given timedelta 'offset' if 473 specified. The returned time will be in the UTC time zone. 474 """ 475 476 offset = offset or timedelta(0) 477 return to_timezone(datetime.utcnow(), "UTC") + offset 478 479 def get_tzid(dtstart_attr, dtend_attr): 480 481 """ 482 Return any time regime details from the given 'dtstart_attr' and 483 'dtend_attr' attribute collections. 484 """ 485 486 return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None 487 488 def get_recurrence_start(recurrenceid): 489 490 """ 491 Return 'recurrenceid' in a form suitable for comparison with period start 492 dates or datetimes. The 'recurrenceid' should be an identifier normalised to 493 a UTC datetime or employing a date or floating datetime representation where 494 no time zone information was originally provided. 495 """ 496 497 return get_datetime(recurrenceid) 498 499 def get_recurrence_start_point(recurrenceid, tzid): 500 501 """ 502 Return 'recurrenceid' in a form suitable for comparison with free/busy start 503 datetimes, using 'tzid' to convert recurrence identifiers that are dates. 504 The 'recurrenceid' should be an identifier normalised to a UTC datetime or 505 employing a date or floating datetime representation where no time zone 506 information was originally provided. 507 """ 508 509 return to_utc_datetime(get_datetime(recurrenceid), tzid) 510 511 # Time corrections. 512 513 class ValidityError(Exception): 514 pass 515 516 def check_permitted_values(dt, permitted_values): 517 518 "Check the datetime 'dt' against the 'permitted_values' list." 519 520 if not isinstance(dt, datetime): 521 raise ValidityError 522 523 hours, minutes, seconds = permitted_values 524 errors = [] 525 526 if hours and dt.hour not in hours: 527 errors.append("hour") 528 if minutes and dt.minute not in minutes: 529 errors.append("minute") 530 if seconds and dt.second not in seconds: 531 errors.append("second") 532 533 return errors 534 535 def correct_datetime(dt, permitted_values): 536 537 "Correct 'dt' using the given 'permitted_values' details." 538 539 carry, hour, minute, second = correct_value((dt.hour, dt.minute, dt.second), permitted_values) 540 return datetime(dt.year, dt.month, dt.day, hour, minute, second, dt.microsecond, dt.tzinfo) + \ 541 (carry and timedelta(1) or timedelta(0)) 542 543 def correct_value(value, permitted_values): 544 545 """ 546 Correct the given (hour, minute, second) tuple 'value' according to the 547 'permitted_values' details. 548 """ 549 550 limits = 23, 59, 59 551 552 corrected = [] 553 reset = False 554 555 # Find invalid values and reset all following values. 556 557 for v, values, limit in zip(value, permitted_values, limits): 558 if reset: 559 if values: 560 v = values[0] 561 else: 562 v = 0 563 564 elif values and v not in values: 565 reset = True 566 567 corrected.append(v) 568 569 value = corrected 570 corrected = [] 571 carry = 0 572 573 # Find invalid values and update them to the next valid value, updating more 574 # significant values if the next valid value is the first in the appropriate 575 # series. 576 577 for v, values, limit in zip(value, permitted_values, limits)[::-1]: 578 if carry: 579 v += 1 580 if v > limit: 581 if values: 582 v = values[0] 583 else: 584 v = 0 585 corrected.append(v) 586 continue 587 else: 588 carry = 0 589 590 if values: 591 i = bisect_left(values, v) 592 if i < len(values): 593 v = values[i] 594 else: 595 v = values[0] 596 carry = 1 597 598 corrected.append(v) 599 600 return [carry] + corrected[::-1] 601 602 # vim: tabstop=4 expandtab shiftwidth=4