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