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