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 get_start_of_day(dt, tzid): 253 254 """ 255 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 256 to obtain a datetime in the appropriate time zone. Where time zone 257 transitions occur within a day, the zone of 'dt' may not be the eventual 258 zone of the returned object. 259 """ 260 261 start = datetime(dt.year, dt.month, dt.day, 0, 0) 262 return to_timezone(start, tzid) 263 264 def get_end_of_day(dt, tzid): 265 266 """ 267 Get the end 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 return get_start_of_day(dt + timedelta(1), tzid) 274 275 def get_start_of_next_day(dt, tzid): 276 277 """ 278 Get the start of the day after the day in which 'dt' is positioned. This 279 function is intended to extend either dates or datetimes to the end of a 280 day for the purpose of generating a missing end date or datetime for an 281 event. 282 283 If 'dt' is a date and not a datetime, a plain date object for the next day 284 will be returned. 285 286 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 287 appropriate time zone. Where time zone transitions occur within a day, the 288 zone of 'dt' may not be the eventual zone of the returned object. 289 """ 290 291 if isinstance(dt, datetime): 292 return get_end_of_day(dt, tzid) 293 else: 294 return dt + timedelta(1) 295 296 def ends_on_same_day(dt, end, tzid): 297 298 """ 299 Return whether 'dt' ends on the same day as 'end', testing the date 300 components of 'dt' and 'end' against each other, but also testing whether 301 'end' is the actual end of the day in which 'dt' is positioned. 302 303 Since time zone transitions may occur within a day, 'tzid' is required to 304 determine the end of the day in which 'dt' is positioned, using the zone 305 appropriate at that point in time, not necessarily the zone applying to 306 'dt'. 307 """ 308 309 return ( 310 dt.date() == end.date() or 311 end == get_end_of_day(dt, tzid) 312 ) 313 314 def get_timestamp(): 315 316 "Return the current time as an iCalendar-compatible string." 317 318 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 319 320 def get_freebusy_period(start, end, tzid): 321 322 """ 323 For the given 'start' datetime, together with the given 'end' datetime, and 324 given a 'tzid' either from the datetimes or provided for the user, return a 325 (start, end) tuple containing datetimes in the UTC time zone, where dates 326 are converted to points in time so that each day has a specific start and 327 end point defined in UTC. 328 """ 329 330 start = to_utc_datetime_only(start, tzid) 331 end = to_utc_datetime_only(end, tzid) 332 return start, end 333 334 def to_utc_datetime_only(dt, tzid): 335 336 """ 337 Return the datetime 'dt' as a point in time in the UTC time zone, given the 338 'tzid' defined for the datetime. Where 'dt' is a date, the start of the 339 indicated day is returned, defined in UTC. 340 """ 341 342 if not isinstance(dt, datetime): 343 return to_timezone(get_start_of_day(dt, tzid), "UTC") 344 else: 345 return to_timezone(dt, "UTC") 346 347 # vim: tabstop=4 expandtab shiftwidth=4