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