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