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 pytz import timezone, UnknownTimeZoneError 24 import re 25 26 # iCalendar date and datetime parsing (from DateSupport in MoinSupport). 27 28 date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 29 datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ 30 ur'(?:' \ 31 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 32 ur'(?P<utc>Z)?' \ 33 ur')?' 34 35 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match 36 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match 37 38 def to_utc_datetime(dt): 39 40 "Return a datetime corresponding to 'dt' in the UTC time zone." 41 42 if not dt: 43 return None 44 elif isinstance(dt, datetime): 45 return to_timezone(dt, "UTC") 46 else: 47 return dt 48 49 def to_timezone(dt, name): 50 51 """ 52 Return a datetime corresponding to 'dt' in the time regime having the given 53 'name'. 54 """ 55 56 try: 57 tz = name and timezone(name) or None 58 except UnknownTimeZoneError: 59 tz = None 60 return to_tz(dt, tz) 61 62 def to_tz(dt, tz): 63 64 "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'." 65 66 if tz is not None and isinstance(dt, datetime): 67 if not dt.tzinfo: 68 return tz.localize(dt) 69 else: 70 return dt.astimezone(tz) 71 else: 72 return dt 73 74 def format_datetime(dt): 75 76 "Format 'dt' as an iCalendar-compatible string." 77 78 if not dt: 79 return None 80 elif isinstance(dt, datetime): 81 if dt.tzname() == "UTC": 82 return dt.strftime("%Y%m%dT%H%M%SZ") 83 else: 84 return dt.strftime("%Y%m%dT%H%M%S") 85 else: 86 return dt.strftime("%Y%m%d") 87 88 def format_time(dt): 89 90 "Format the time portion of 'dt' as an iCalendar-compatible string." 91 92 if not dt: 93 return None 94 elif isinstance(dt, datetime): 95 if dt.tzname() == "UTC": 96 return dt.strftime("%H%M%SZ") 97 else: 98 return dt.strftime("%H%M%S") 99 else: 100 return None 101 102 def get_datetime_item(dt, tzid): 103 104 "Return an iCalendar-compatible string and attributes for 'dt' and 'tzid'." 105 106 if not dt: 107 return None, None 108 value = format_datetime(dt) 109 attr = isinstance(dt, datetime) and {"TZID" : tzid, "VALUE" : "DATE-TIME"} or {"VALUE" : "DATE"} 110 return value, attr 111 112 def get_datetime(value, attr=None): 113 114 """ 115 Return a datetime object from the given 'value' in iCalendar format, using 116 the 'attr' mapping (if specified) to control the conversion. 117 """ 118 119 if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")): 120 m = match_datetime_icalendar(value) 121 if m: 122 year, month, day, hour, minute, second = map(m.group, [ 123 "year", "month", "day", "hour", "minute", "second" 124 ]) 125 126 if hour and minute and second: 127 dt = datetime( 128 int(year), int(month), int(day), int(hour), int(minute), int(second) 129 ) 130 131 # Impose the indicated timezone. 132 # NOTE: This needs an ambiguity policy for DST changes. 133 134 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 135 136 return None 137 138 # Permit dates even if the VALUE is not set to DATE. 139 140 if not attr or attr.get("VALUE") in (None, "DATE"): 141 m = match_date_icalendar(value) 142 if m: 143 year, month, day = map(m.group, ["year", "month", "day"]) 144 return date(int(year), int(month), int(day)) 145 146 return None 147 148 def get_date(dt): 149 150 "Return the date of 'dt'." 151 152 return date(dt.year, dt.month, dt.day) 153 154 def get_start_of_day(dt, tzid): 155 156 """ 157 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 158 to obtain a datetime in the appropriate time zone. Where time zone 159 transitions occur within a day, the zone of 'dt' may not be the eventual 160 zone of the returned object. 161 """ 162 163 start = datetime(dt.year, dt.month, dt.day, 0, 0) 164 return to_timezone(start, tzid) 165 166 def get_end_of_day(dt, tzid): 167 168 """ 169 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 170 to obtain a datetime in the appropriate time zone. Where time zone 171 transitions occur within a day, the zone of 'dt' may not be the eventual 172 zone of the returned object. 173 """ 174 175 return get_start_of_day(dt + timedelta(1), tzid) 176 177 def get_start_of_next_day(dt, tzid): 178 179 """ 180 Get the start of the day after the day in which 'dt' is positioned. This 181 function is intended to extend either dates or datetimes to the end of a 182 day for the purpose of generating a missing end date or datetime for an 183 event. 184 185 If 'dt' is a date and not a datetime, a plain date object for the next day 186 will be returned. 187 188 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 189 appropriate time zone. Where time zone transitions occur within a day, the 190 zone of 'dt' may not be the eventual zone of the returned object. 191 """ 192 193 if isinstance(dt, datetime): 194 return get_end_of_day(dt, tzid) 195 else: 196 return dt + timedelta(1) 197 198 def ends_on_same_day(dt, end, tzid): 199 200 """ 201 Return whether 'dt' ends on the same day as 'end', testing the date 202 components of 'dt' and 'end' against each other, but also testing whether 203 'end' is the actual end of the day in which 'dt' is positioned. 204 205 Since time zone transitions may occur within a day, 'tzid' is required to 206 determine the end of the day in which 'dt' is positioned, using the zone 207 appropriate at that point in time, not necessarily the zone applying to 208 'dt'. 209 """ 210 211 return ( 212 dt.date() == end.date() or 213 end == get_end_of_day(dt, tzid) 214 ) 215 216 def get_timestamp(): 217 218 "Return the current time as an iCalendar-compatible string." 219 220 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 221 222 # vim: tabstop=4 expandtab shiftwidth=4