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