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