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): 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 attr = isinstance(dt, datetime) and {"TZID" : tzid, "VALUE" : "DATE-TIME"} or {"VALUE" : "DATE"} 126 return value, attr 127 128 def get_datetime(value, attr=None): 129 130 """ 131 Return a datetime object from the given 'value' in iCalendar format, using 132 the 'attr' mapping (if specified) to control the conversion. 133 """ 134 135 if not value: 136 return None 137 138 if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")): 139 m = match_datetime_icalendar(value) 140 if m: 141 year, month, day, hour, minute, second = map(m.group, [ 142 "year", "month", "day", "hour", "minute", "second" 143 ]) 144 145 if hour and minute and second: 146 dt = datetime( 147 int(year), int(month), int(day), int(hour), int(minute), int(second) 148 ) 149 150 # Impose the indicated timezone. 151 # NOTE: This needs an ambiguity policy for DST changes. 152 153 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 154 155 return None 156 157 # Permit dates even if the VALUE is not set to DATE. 158 159 if not attr or attr.get("VALUE") in (None, "DATE"): 160 m = match_date_icalendar(value) 161 if m: 162 year, month, day = map(m.group, ["year", "month", "day"]) 163 return date(int(year), int(month), int(day)) 164 165 return None 166 167 def get_date(dt): 168 169 "Return the date of 'dt'." 170 171 return date(dt.year, dt.month, dt.day) 172 173 def get_start_of_day(dt, tzid): 174 175 """ 176 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 177 to obtain a datetime in the appropriate time zone. Where time zone 178 transitions occur within a day, the zone of 'dt' may not be the eventual 179 zone of the returned object. 180 """ 181 182 start = datetime(dt.year, dt.month, dt.day, 0, 0) 183 return to_timezone(start, tzid) 184 185 def get_end_of_day(dt, tzid): 186 187 """ 188 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 189 to obtain a datetime in the appropriate time zone. Where time zone 190 transitions occur within a day, the zone of 'dt' may not be the eventual 191 zone of the returned object. 192 """ 193 194 return get_start_of_day(dt + timedelta(1), tzid) 195 196 def get_start_of_next_day(dt, tzid): 197 198 """ 199 Get the start of the day after the day in which 'dt' is positioned. This 200 function is intended to extend either dates or datetimes to the end of a 201 day for the purpose of generating a missing end date or datetime for an 202 event. 203 204 If 'dt' is a date and not a datetime, a plain date object for the next day 205 will be returned. 206 207 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 208 appropriate time zone. Where time zone transitions occur within a day, the 209 zone of 'dt' may not be the eventual zone of the returned object. 210 """ 211 212 if isinstance(dt, datetime): 213 return get_end_of_day(dt, tzid) 214 else: 215 return dt + timedelta(1) 216 217 def ends_on_same_day(dt, end, tzid): 218 219 """ 220 Return whether 'dt' ends on the same day as 'end', testing the date 221 components of 'dt' and 'end' against each other, but also testing whether 222 'end' is the actual end of the day in which 'dt' is positioned. 223 224 Since time zone transitions may occur within a day, 'tzid' is required to 225 determine the end of the day in which 'dt' is positioned, using the zone 226 appropriate at that point in time, not necessarily the zone applying to 227 'dt'. 228 """ 229 230 return ( 231 dt.date() == end.date() or 232 end == get_end_of_day(dt, tzid) 233 ) 234 235 def get_timestamp(): 236 237 "Return the current time as an iCalendar-compatible string." 238 239 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 240 241 def get_freebusy_period(start, end, tzid): 242 243 """ 244 For the given 'start' datetime, together with the given 'end' datetime, and 245 given a 'tzid' either from the datetimes or provided for the user, return a 246 (start, end) tuple containing datetimes in the UTC time zone, where dates 247 are converted to points in time so that each day has a specific start and 248 end point defined in UTC. 249 """ 250 251 start = to_utc_datetime_only(start, tzid) 252 end = to_utc_datetime_only(end, tzid) 253 return start, end 254 255 def to_utc_datetime_only(dt, tzid): 256 257 """ 258 Return the datetime 'dt' as a point in time in the UTC time zone, given the 259 'tzid' defined for the datetime. Where 'dt' is a date, the start of the 260 indicated day is returned, defined in UTC. 261 """ 262 263 if not isinstance(dt, datetime): 264 return to_timezone(get_start_of_day(dt, tzid), "UTC") 265 else: 266 return to_timezone(dt, "UTC") 267 268 # vim: tabstop=4 expandtab shiftwidth=4