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_datetime_attributes(dt, tzid=None): 147 148 "Return attributes for 'dt' and 'tzid'." 149 150 if isinstance(dt, datetime): 151 attr = {"VALUE" : "DATE-TIME"} 152 if tzid: 153 attr["TZID"] = tzid 154 return attr 155 else: 156 return {"VALUE" : "DATE"} 157 158 return {} 159 160 def get_period_attributes(tzid=None): 161 162 "Return attributes for 'tzid'." 163 164 attr = {"VALUE" : "PERIOD"} 165 if tzid: 166 attr["TZID"] = tzid 167 return attr 168 169 def get_datetime_item(dt, tzid=None): 170 171 "Return an iCalendar-compatible string and attributes for 'dt' and 'tzid'." 172 173 if not dt: 174 return None, None 175 dt = to_timezone(dt, tzid) 176 value = format_datetime(dt) 177 attr = get_datetime_attributes(dt, tzid) 178 return value, attr 179 180 def get_period_item(start, end, tzid=None): 181 182 """ 183 Return an iCalendar-compatible string and attributes for 'start', 'end' and 184 'tzid'. 185 """ 186 187 start = start and to_timezone(start, tzid) 188 end = end and to_timezone(end, tzid) 189 190 start_value = start and format_datetime(start) or None 191 end_value = end and format_datetime(end) or None 192 193 if start and end: 194 attr = get_period_attributes(tzid) 195 return "%s/%s" % (start_value, end_value), attr 196 elif start: 197 attr = get_datetime_attributes(start, tzid) 198 return start_value, attr 199 else: 200 return None, None 201 202 def get_datetime(value, attr=None): 203 204 """ 205 Return a datetime object from the given 'value' in iCalendar format, using 206 the 'attr' mapping (if specified) to control the conversion. 207 """ 208 209 if not value: 210 return None 211 212 if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")): 213 m = match_datetime_icalendar(value) 214 if m: 215 year, month, day, hour, minute, second = map(m.group, [ 216 "year", "month", "day", "hour", "minute", "second" 217 ]) 218 219 if hour and minute and second: 220 dt = datetime( 221 int(year), int(month), int(day), int(hour), int(minute), int(second) 222 ) 223 224 # Impose the indicated timezone. 225 # NOTE: This needs an ambiguity policy for DST changes. 226 227 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 228 229 return None 230 231 # Permit dates even if the VALUE is not set to DATE. 232 233 if not attr or attr.get("VALUE") in (None, "DATE"): 234 m = match_date_icalendar(value) 235 if m: 236 year, month, day = map(m.group, ["year", "month", "day"]) 237 return date(int(year), int(month), int(day)) 238 239 return None 240 241 def get_period(value, attr=None): 242 243 """ 244 Return a tuple of the form (start, end) for the given 'value' in iCalendar 245 format, using the 'attr' mapping (if specified) to control the conversion. 246 """ 247 248 if not value or attr and attr.get("VALUE") != "PERIOD": 249 return None 250 251 t = value.split("/") 252 if len(t) != 2: 253 return None 254 255 dtattr = {} 256 if attr: 257 dtattr.update(attr) 258 if dtattr.has_key("VALUE"): 259 del dtattr["VALUE"] 260 261 start = get_datetime(t[0], dtattr) 262 if t[1].startswith("P"): 263 end = start + get_duration(t[1]) 264 else: 265 end = get_datetime(t[1], dtattr) 266 267 return start, end 268 269 def get_duration(value): 270 271 "Return a duration for the given 'value'." 272 273 if not value: 274 return None 275 276 m = match_duration_icalendar(value) 277 if m: 278 weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0 279 for s in m.groups(): 280 if not s: continue 281 if s[-1] == "W": weeks += int(s[:-1]) 282 elif s[-1] == "D": days += int(s[:-1]) 283 elif s[-1] == "H": hours += int(s[:-1]) 284 elif s[-1] == "M": minutes += int(s[:-1]) 285 elif s[-1] == "S": seconds += int(s[:-1]) 286 return timedelta( 287 int(weeks) * 7 + int(days), 288 (int(hours) * 60 + int(minutes)) * 60 + int(seconds) 289 ) 290 else: 291 return None 292 293 def to_date(dt): 294 295 "Return the date of 'dt'." 296 297 return date(dt.year, dt.month, dt.day) 298 299 def to_datetime(dt, tzid): 300 301 """ 302 Return a datetime for 'dt', using the start of day for dates, and using the 303 'tzid' for the conversion. 304 """ 305 306 if isinstance(dt, datetime): 307 return dt 308 else: 309 return get_start_of_day(dt, tzid) 310 311 def get_start_of_day(dt, tzid): 312 313 """ 314 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 315 to obtain a datetime in the appropriate time zone. Where time zone 316 transitions occur within a day, the zone of 'dt' may not be the eventual 317 zone of the returned object. 318 """ 319 320 start = datetime(dt.year, dt.month, dt.day, 0, 0) 321 return to_timezone(start, tzid) 322 323 def get_end_of_day(dt, tzid): 324 325 """ 326 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 327 to obtain a datetime in the appropriate time zone. Where time zone 328 transitions occur within a day, the zone of 'dt' may not be the eventual 329 zone of the returned object. 330 """ 331 332 return get_start_of_day(dt + timedelta(1), tzid) 333 334 def get_start_of_next_day(dt, tzid): 335 336 """ 337 Get the start of the day after the day in which 'dt' is positioned. This 338 function is intended to extend either dates or datetimes to the end of a 339 day for the purpose of generating a missing end date or datetime for an 340 event. 341 342 If 'dt' is a date and not a datetime, a plain date object for the next day 343 will be returned. 344 345 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 346 appropriate time zone. Where time zone transitions occur within a day, the 347 zone of 'dt' may not be the eventual zone of the returned object. 348 """ 349 350 if isinstance(dt, datetime): 351 return get_end_of_day(dt, tzid) 352 else: 353 return dt + timedelta(1) 354 355 def ends_on_same_day(dt, end, tzid): 356 357 """ 358 Return whether 'dt' ends on the same day as 'end', testing the date 359 components of 'dt' and 'end' against each other, but also testing whether 360 'end' is the actual end of the day in which 'dt' is positioned. 361 362 Since time zone transitions may occur within a day, 'tzid' is required to 363 determine the end of the day in which 'dt' is positioned, using the zone 364 appropriate at that point in time, not necessarily the zone applying to 365 'dt'. 366 """ 367 368 return ( 369 dt.date() == end.date() or 370 end == get_end_of_day(dt, tzid) 371 ) 372 373 def get_timestamp(): 374 375 "Return the current time as an iCalendar-compatible string." 376 377 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 378 379 def get_freebusy_period(start, end, tzid): 380 381 """ 382 For the given 'start' datetime, together with the given 'end' datetime, and 383 given a 'tzid' either from the datetimes or provided for the user, return a 384 (start, end) tuple containing datetimes in the UTC time zone, where dates 385 are converted to points in time so that each day has a specific start and 386 end point defined in UTC. 387 """ 388 389 start = to_utc_datetime(start, tzid) 390 end = to_utc_datetime(end, tzid) 391 return start, end 392 393 def to_recurrence_start(recurrenceid, tzid): 394 395 """ 396 Return 'recurrenceid' in a form suitable for comparison with free/busy start 397 datetimes, using 'tzid' to convert recurrence identifiers that are dates. 398 """ 399 400 return format_datetime(to_utc_datetime(get_datetime(recurrenceid), tzid)) 401 402 # vim: tabstop=4 expandtab shiftwidth=4