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