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