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 datetime import date, datetime, timedelta 23 from os.path import exists 24 from pytz import timezone, UnknownTimeZoneError 25 import re 26 27 # iCalendar date and datetime parsing (from DateSupport in MoinSupport). 28 29 _date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 30 date_icalendar_regexp_str = _date_icalendar_regexp_str + '$' 31 32 datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \ 33 ur'(?:' \ 34 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 35 ur'(?P<utc>Z)?' \ 36 ur')?$' 37 38 _duration_time_icalendar_regexp_str = \ 39 ur'T' \ 40 ur'(?:' \ 41 ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \ 42 ur'|' \ 43 ur'([0-9]+M)([0-9]+S)?' \ 44 ur'|' \ 45 ur'([0-9]+S)' \ 46 ur')' 47 48 duration_icalendar_regexp_str = ur'P' \ 49 ur'(?:' \ 50 ur'([0-9]+W)' \ 51 ur'|' \ 52 ur'(?:%s)' \ 53 ur'|' \ 54 ur'([0-9]+D)(?:%s)?' \ 55 ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str) 56 57 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match 58 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match 59 match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match 60 61 # Datetime formatting. 62 63 def format_datetime(dt): 64 65 "Format 'dt' as an iCalendar-compatible string." 66 67 if not dt: 68 return None 69 elif isinstance(dt, datetime): 70 if dt.tzname() == "UTC": 71 return dt.strftime("%Y%m%dT%H%M%SZ") 72 else: 73 return dt.strftime("%Y%m%dT%H%M%S") 74 else: 75 return dt.strftime("%Y%m%d") 76 77 def format_time(dt): 78 79 "Format the time portion of 'dt' as an iCalendar-compatible string." 80 81 if not dt: 82 return None 83 elif isinstance(dt, datetime): 84 if dt.tzname() == "UTC": 85 return dt.strftime("%H%M%SZ") 86 else: 87 return dt.strftime("%H%M%S") 88 else: 89 return None 90 91 # Parsing of datetime and related information. 92 93 def get_datetime(value, attr=None): 94 95 """ 96 Return a datetime object from the given 'value' in iCalendar format, using 97 the 'attr' mapping (if specified) to control the conversion. 98 """ 99 100 if not value: 101 return None 102 103 if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")): 104 m = match_datetime_icalendar(value) 105 if m: 106 year, month, day, hour, minute, second = map(m.group, [ 107 "year", "month", "day", "hour", "minute", "second" 108 ]) 109 110 if hour and minute and second: 111 dt = datetime( 112 int(year), int(month), int(day), int(hour), int(minute), int(second) 113 ) 114 115 # Impose the indicated timezone. 116 # NOTE: This needs an ambiguity policy for DST changes. 117 118 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 119 120 return None 121 122 # Permit dates even if the VALUE is not set to DATE. 123 124 if not attr or attr.get("VALUE") in (None, "DATE"): 125 m = match_date_icalendar(value) 126 if m: 127 year, month, day = map(m.group, ["year", "month", "day"]) 128 return date(int(year), int(month), int(day)) 129 130 return None 131 132 def get_duration(value): 133 134 "Return a duration for the given 'value'." 135 136 if not value: 137 return None 138 139 m = match_duration_icalendar(value) 140 if m: 141 weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0 142 for s in m.groups(): 143 if not s: continue 144 if s[-1] == "W": weeks += int(s[:-1]) 145 elif s[-1] == "D": days += int(s[:-1]) 146 elif s[-1] == "H": hours += int(s[:-1]) 147 elif s[-1] == "M": minutes += int(s[:-1]) 148 elif s[-1] == "S": seconds += int(s[:-1]) 149 return timedelta( 150 int(weeks) * 7 + int(days), 151 (int(hours) * 60 + int(minutes)) * 60 + int(seconds) 152 ) 153 else: 154 return None 155 156 def get_period(value, attr=None): 157 158 """ 159 Return a tuple of the form (start, end) for the given 'value' in iCalendar 160 format, using the 'attr' mapping (if specified) to control the conversion. 161 """ 162 163 if not value or attr and attr.get("VALUE") != "PERIOD": 164 return None 165 166 t = value.split("/") 167 if len(t) != 2: 168 return None 169 170 dtattr = {} 171 if attr: 172 dtattr.update(attr) 173 if dtattr.has_key("VALUE"): 174 del dtattr["VALUE"] 175 176 start = get_datetime(t[0], dtattr) 177 if t[1].startswith("P"): 178 end = start + get_duration(t[1]) 179 else: 180 end = get_datetime(t[1], dtattr) 181 182 return start, end 183 184 # Time zone conversions and retrieval. 185 186 def ends_on_same_day(dt, end, tzid): 187 188 """ 189 Return whether 'dt' ends on the same day as 'end', testing the date 190 components of 'dt' and 'end' against each other, but also testing whether 191 'end' is the actual end of the day in which 'dt' is positioned. 192 193 Since time zone transitions may occur within a day, 'tzid' is required to 194 determine the end of the day in which 'dt' is positioned, using the zone 195 appropriate at that point in time, not necessarily the zone applying to 196 'dt'. 197 """ 198 199 return ( 200 to_timezone(dt, tzid).date() == to_timezone(end, tzid).date() or 201 end == get_end_of_day(dt, tzid) 202 ) 203 204 def get_default_timezone(): 205 206 "Return the system time regime." 207 208 filename = "/etc/timezone" 209 210 if exists(filename): 211 f = open(filename) 212 try: 213 return f.read().strip() 214 finally: 215 f.close() 216 else: 217 return None 218 219 def get_end_of_day(dt, tzid): 220 221 """ 222 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 223 to obtain a datetime in the appropriate time zone. Where time zone 224 transitions occur within a day, the zone of 'dt' may not be the eventual 225 zone of the returned object. 226 """ 227 228 return get_start_of_day(dt + timedelta(1), tzid) 229 230 def get_start_of_day(dt, tzid): 231 232 """ 233 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 234 to obtain a datetime in the appropriate time zone. Where time zone 235 transitions occur within a day, the zone of 'dt' may not be the eventual 236 zone of the returned object. 237 """ 238 239 start = datetime(dt.year, dt.month, dt.day, 0, 0) 240 return to_timezone(start, tzid) 241 242 def get_start_of_next_day(dt, tzid): 243 244 """ 245 Get the start of the day after the day in which 'dt' is positioned. This 246 function is intended to extend either dates or datetimes to the end of a 247 day for the purpose of generating a missing end date or datetime for an 248 event. 249 250 If 'dt' is a date and not a datetime, a plain date object for the next day 251 will be returned. 252 253 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 254 appropriate time zone. Where time zone transitions occur within a day, the 255 zone of 'dt' may not be the eventual zone of the returned object. 256 """ 257 258 if isinstance(dt, datetime): 259 return get_end_of_day(dt, tzid) 260 else: 261 return dt + timedelta(1) 262 263 def get_datetime_tzid(dt): 264 265 "Return the time zone identifier from 'dt' or None if unknown." 266 267 if not isinstance(dt, datetime): 268 return None 269 elif dt.tzname() == "UTC": 270 return "UTC" 271 elif dt.tzinfo and hasattr(dt.tzinfo, "zone"): 272 return dt.tzinfo.zone 273 else: 274 return None 275 276 def to_date(dt): 277 278 "Return the date of 'dt'." 279 280 return date(dt.year, dt.month, dt.day) 281 282 def to_datetime(dt, tzid): 283 284 """ 285 Return a datetime for 'dt', using the start of day for dates, and using the 286 'tzid' for the conversion. 287 """ 288 289 if isinstance(dt, datetime): 290 return dt 291 else: 292 return get_start_of_day(dt, tzid) 293 294 def to_utc_datetime(dt, date_tzid=None): 295 296 """ 297 Return a datetime corresponding to 'dt' in the UTC time zone. If 'date_tzid' 298 is specified, dates are converted to datetimes using the time zone 299 information; otherwise, dates remain unconverted. 300 """ 301 302 if not dt: 303 return None 304 elif isinstance(dt, datetime): 305 return to_timezone(dt, "UTC") 306 elif date_tzid: 307 return to_timezone(to_datetime(dt, date_tzid), "UTC") 308 else: 309 return dt 310 311 def to_timezone(dt, tzid): 312 313 """ 314 Return a datetime corresponding to 'dt' in the time regime having the given 315 'tzid'. 316 """ 317 318 try: 319 tz = tzid and timezone(tzid) or None 320 except UnknownTimeZoneError: 321 tz = None 322 return to_tz(dt, tz) 323 324 def to_tz(dt, tz): 325 326 "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'." 327 328 if tz is not None and isinstance(dt, datetime): 329 if not dt.tzinfo: 330 return tz.localize(dt) 331 else: 332 return dt.astimezone(tz) 333 else: 334 return dt 335 336 # iCalendar-related conversions. 337 338 def end_date_from_calendar(dt): 339 340 """ 341 Change end dates to refer to the actual dates, not the iCalendar "next day" 342 dates. 343 """ 344 345 if not isinstance(dt, datetime): 346 return dt - timedelta(1) 347 else: 348 return dt 349 350 def end_date_to_calendar(dt): 351 352 """ 353 Change end dates to refer to the iCalendar "next day" dates, not the actual 354 dates. 355 """ 356 357 if not isinstance(dt, datetime): 358 return dt + timedelta(1) 359 else: 360 return dt 361 362 def get_datetime_attributes(dt, tzid=None): 363 364 """ 365 Return attributes for the 'dt' date or datetime object with 'tzid' 366 indicating the time zone if not otherwise defined. 367 """ 368 369 if isinstance(dt, datetime): 370 attr = {"VALUE" : "DATE-TIME"} 371 tzid = get_datetime_tzid(dt) or tzid 372 if tzid: 373 attr["TZID"] = tzid 374 return attr 375 else: 376 return {"VALUE" : "DATE"} 377 378 return {} 379 380 def get_datetime_item(dt, tzid=None): 381 382 """ 383 Return an iCalendar-compatible string and attributes for 'dt' using any 384 specified 'tzid' to assert a particular time zone if not otherwise defined. 385 """ 386 387 if not dt: 388 return None, None 389 if not get_datetime_tzid(dt): 390 dt = to_timezone(dt, tzid) 391 value = format_datetime(dt) 392 attr = get_datetime_attributes(dt, tzid) 393 return value, attr 394 395 def get_period_attributes(tzid=None): 396 397 "Return attributes for 'tzid'." 398 399 attr = {"VALUE" : "PERIOD"} 400 if tzid: 401 attr["TZID"] = tzid 402 return attr 403 404 def get_period_item(start, end, tzid=None): 405 406 """ 407 Return an iCalendar-compatible string and attributes for 'start', 'end' and 408 'tzid'. 409 """ 410 411 start = start and to_timezone(start, tzid) 412 end = end and to_timezone(end, tzid) 413 414 start_value = start and format_datetime(start) or None 415 end_value = end and format_datetime(end) or None 416 417 if start and end: 418 attr = get_period_attributes(tzid) 419 return "%s/%s" % (start_value, end_value), attr 420 elif start: 421 attr = get_datetime_attributes(start, tzid) 422 return start_value, attr 423 else: 424 return None, None 425 426 def get_timestamp(): 427 428 "Return the current time as an iCalendar-compatible string." 429 430 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 431 432 def get_tzid(dtstart_attr, dtend_attr): 433 434 """ 435 Return any time regime details from the given 'dtstart_attr' and 436 'dtend_attr' attribute collections. 437 """ 438 439 return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None 440 441 def get_recurrence_start(recurrenceid): 442 443 """ 444 Return 'recurrenceid' in a form suitable for comparison with period start 445 dates or datetimes. 446 """ 447 448 return get_datetime(recurrenceid) 449 450 def get_recurrence_start_point(recurrenceid, tzid): 451 452 """ 453 Return 'recurrenceid' in a form suitable for comparison with free/busy start 454 datetimes, using 'tzid' to convert recurrence identifiers that are dates. 455 """ 456 457 return to_utc_datetime(get_datetime(recurrenceid), tzid) 458 459 def to_recurrence_start(recurrenceid): 460 461 """ 462 Return 'recurrenceid' in a form suitable for use as an unambiguous 463 identifier. 464 """ 465 466 return format_datetime(get_recurrence_start(recurrenceid)) 467 468 def to_recurrence_start_point(recurrenceid, tzid): 469 470 """ 471 Return 'recurrenceid' in a form suitable for use as an unambiguous 472 identifier, using 'tzid' to convert recurrence identifiers that are dates. 473 """ 474 475 return format_datetime(get_recurrence_start_point(recurrenceid, tzid)) 476 477 # vim: tabstop=4 expandtab shiftwidth=4