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