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 return format_datetime(get_time(offset)) 457 458 def get_date(offset=None): 459 460 """ 461 Return the current date, offset by the given timedelta 'offset' if 462 specified. The returned date will not be positioned in any time zone. 463 """ 464 465 offset = offset or timedelta(0) 466 return date.today() + offset 467 468 def get_time(offset=None): 469 470 """ 471 Return the current time, offset by the given timedelta 'offset' if 472 specified. The returned time will be in the UTC time zone. 473 """ 474 475 offset = offset or timedelta(0) 476 return to_timezone(datetime.utcnow(), "UTC") + offset 477 478 def get_tzid(dtstart_attr, dtend_attr): 479 480 """ 481 Return any time regime details from the given 'dtstart_attr' and 482 'dtend_attr' attribute collections. 483 """ 484 485 return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None 486 487 def get_recurrence_start(recurrenceid): 488 489 """ 490 Return 'recurrenceid' in a form suitable for comparison with period start 491 dates or datetimes. The 'recurrenceid' should be an identifier normalised to 492 a UTC datetime or employing a date or floating datetime representation where 493 no time zone information was originally provided. 494 """ 495 496 return get_datetime(recurrenceid) 497 498 def get_recurrence_start_point(recurrenceid, tzid): 499 500 """ 501 Return 'recurrenceid' in a form suitable for comparison with free/busy start 502 datetimes, using 'tzid' to convert recurrence identifiers that are dates. 503 The 'recurrenceid' should be an identifier normalised to a UTC datetime or 504 employing a date or floating datetime representation where no time zone 505 information was originally provided. 506 """ 507 508 return to_utc_datetime(get_datetime(recurrenceid), tzid) 509 510 # Time corrections. 511 512 class ValidityError(Exception): 513 pass 514 515 def check_permitted_values(dt, permitted_values): 516 517 "Check the datetime 'dt' against the 'permitted_values' list." 518 519 if not isinstance(dt, datetime): 520 raise ValidityError 521 522 hours, minutes, seconds = permitted_values 523 errors = [] 524 525 if hours and dt.hour not in hours: 526 errors.append("hour") 527 if minutes and dt.minute not in minutes: 528 errors.append("minute") 529 if seconds and dt.second not in seconds: 530 errors.append("second") 531 532 return errors 533 534 def correct_datetime(dt, permitted_values): 535 536 "Correct 'dt' using the given 'permitted_values' details." 537 538 carry, hour, minute, second = correct_value((dt.hour, dt.minute, dt.second), permitted_values) 539 return datetime(dt.year, dt.month, dt.day, hour, minute, second, dt.microsecond, dt.tzinfo) + \ 540 (carry and timedelta(1) or timedelta(0)) 541 542 def correct_value(value, permitted_values): 543 544 """ 545 Correct the given (hour, minute, second) tuple 'value' according to the 546 'permitted_values' details. 547 """ 548 549 limits = 23, 59, 59 550 551 corrected = [] 552 reset = False 553 554 # Find invalid values and reset all following values. 555 556 for v, values, limit in zip(value, permitted_values, limits): 557 if reset: 558 if values: 559 v = values[0] 560 else: 561 v = 0 562 563 elif values and v not in values: 564 reset = True 565 566 corrected.append(v) 567 568 value = corrected 569 corrected = [] 570 carry = 0 571 572 # Find invalid values and update them to the next valid value, updating more 573 # significant values if the next valid value is the first in the appropriate 574 # series. 575 576 for v, values, limit in zip(value, permitted_values, limits)[::-1]: 577 if carry: 578 v += 1 579 if v > limit: 580 if values: 581 v = values[0] 582 else: 583 v = 0 584 corrected.append(v) 585 continue 586 else: 587 carry = 0 588 589 if values: 590 i = bisect_left(values, v) 591 if i < len(values): 592 v = values[i] 593 else: 594 v = values[0] 595 carry = 1 596 597 corrected.append(v) 598 599 return [carry] + corrected[::-1] 600 601 # vim: tabstop=4 expandtab shiftwidth=4