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