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 to_date(dt): 264 265 "Return the date of 'dt'." 266 267 return date(dt.year, dt.month, dt.day) 268 269 def to_datetime(dt, tzid): 270 271 """ 272 Return a datetime for 'dt', using the start of day for dates, and using the 273 'tzid' for the conversion. 274 """ 275 276 if isinstance(dt, datetime): 277 return dt 278 else: 279 return get_start_of_day(dt, tzid) 280 281 def to_utc_datetime(dt, date_tzid=None): 282 283 """ 284 Return a datetime corresponding to 'dt' in the UTC time zone. If 'date_tzid' 285 is specified, dates are converted to datetimes using the time zone 286 information; otherwise, dates remain unconverted. 287 """ 288 289 if not dt: 290 return None 291 elif isinstance(dt, datetime): 292 return to_timezone(dt, "UTC") 293 elif date_tzid: 294 return to_timezone(to_datetime(dt, date_tzid), "UTC") 295 else: 296 return dt 297 298 def to_timezone(dt, name): 299 300 """ 301 Return a datetime corresponding to 'dt' in the time regime having the given 302 'name'. 303 """ 304 305 try: 306 tz = name and timezone(name) or None 307 except UnknownTimeZoneError: 308 tz = None 309 return to_tz(dt, tz) 310 311 def to_tz(dt, tz): 312 313 "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'." 314 315 if tz is not None and isinstance(dt, datetime): 316 if not dt.tzinfo: 317 return tz.localize(dt) 318 else: 319 return dt.astimezone(tz) 320 else: 321 return dt 322 323 def get_freebusy_period(start, end, tzid): 324 325 """ 326 For the given 'start' datetime, together with the given 'end' datetime, and 327 given a 'tzid' either from the datetimes or provided for the user, return a 328 (start, end) tuple containing datetimes in the UTC time zone, where dates 329 are converted to points in time so that each day has a specific start and 330 end point defined in UTC. 331 """ 332 333 start = to_utc_datetime(start, tzid) 334 end = to_utc_datetime(end, tzid) 335 return start, end 336 337 # iCalendar-related conversions. 338 339 def end_date_from_calendar(dt): 340 341 """ 342 Change end dates to refer to the actual dates, not the iCalendar "next day" 343 dates. 344 """ 345 346 if not isinstance(dt, datetime): 347 return dt - timedelta(1) 348 else: 349 return dt 350 351 def end_date_to_calendar(dt): 352 353 """ 354 Change end dates to refer to the iCalendar "next day" dates, not the actual 355 dates. 356 """ 357 358 if not isinstance(dt, datetime): 359 return dt + timedelta(1) 360 else: 361 return dt 362 363 def get_datetime_attributes(dt, tzid=None): 364 365 "Return attributes for 'dt' and 'tzid'." 366 367 if isinstance(dt, datetime): 368 attr = {"VALUE" : "DATE-TIME"} 369 if tzid: 370 attr["TZID"] = tzid 371 return attr 372 else: 373 return {"VALUE" : "DATE"} 374 375 return {} 376 377 def get_datetime_item(dt, tzid=None): 378 379 "Return an iCalendar-compatible string and attributes for 'dt' and 'tzid'." 380 381 if not dt: 382 return None, None 383 dt = to_timezone(dt, tzid) 384 value = format_datetime(dt) 385 attr = get_datetime_attributes(dt, tzid) 386 return value, attr 387 388 def get_period_attributes(tzid=None): 389 390 "Return attributes for 'tzid'." 391 392 attr = {"VALUE" : "PERIOD"} 393 if tzid: 394 attr["TZID"] = tzid 395 return attr 396 397 def get_period_item(start, end, tzid=None): 398 399 """ 400 Return an iCalendar-compatible string and attributes for 'start', 'end' and 401 'tzid'. 402 """ 403 404 start = start and to_timezone(start, tzid) 405 end = end and to_timezone(end, tzid) 406 407 start_value = start and format_datetime(start) or None 408 end_value = end and format_datetime(end) or None 409 410 if start and end: 411 attr = get_period_attributes(tzid) 412 return "%s/%s" % (start_value, end_value), attr 413 elif start: 414 attr = get_datetime_attributes(start, tzid) 415 return start_value, attr 416 else: 417 return None, None 418 419 def get_timestamp(): 420 421 "Return the current time as an iCalendar-compatible string." 422 423 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 424 425 def get_tzid(dtstart_attr, dtend_attr): 426 427 """ 428 Return any time regime details from the given 'dtstart_attr' and 429 'dtend_attr' attribute collections. 430 """ 431 432 return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None 433 434 def get_recurrence_start(recurrenceid): 435 436 """ 437 Return 'recurrenceid' in a form suitable for comparison with period start 438 dates or datetimes. 439 """ 440 441 return get_datetime(recurrenceid) 442 443 def get_recurrence_start_point(recurrenceid, tzid): 444 445 """ 446 Return 'recurrenceid' in a form suitable for comparison with free/busy start 447 datetimes, using 'tzid' to convert recurrence identifiers that are dates. 448 """ 449 450 return to_utc_datetime(get_datetime(recurrenceid), tzid) 451 452 def to_recurrence_start(recurrenceid): 453 454 """ 455 Return 'recurrenceid' in a form suitable for use as an unambiguous 456 identifier. 457 """ 458 459 return format_datetime(get_recurrence_start(recurrenceid)) 460 461 def to_recurrence_start_point(recurrenceid, tzid): 462 463 """ 464 Return 'recurrenceid' in a form suitable for use as an unambiguous 465 identifier, using 'tzid' to convert recurrence identifiers that are dates. 466 """ 467 468 return format_datetime(get_recurrence_start_point(recurrenceid, tzid)) 469 470 # vim: tabstop=4 expandtab shiftwidth=4