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