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 get_datetime_item(dt, tzid): 89 90 "Return an iCalendar-compatible string and attributes for 'dt' and 'tzid'." 91 92 if not dt: 93 return None, None 94 value = format_datetime(dt) 95 attr = isinstance(dt, datetime) and {"TZID" : tzid, "VALUE" : "DATE-TIME"} or {"VALUE" : "DATE"} 96 return value, attr 97 98 def get_datetime(value, attr=None): 99 100 """ 101 Return a datetime object from the given 'value' in iCalendar format, using 102 the 'attr' mapping (if specified) to control the conversion. 103 """ 104 105 if not attr or attr.get("VALUE") in (None, "DATE-TIME"): 106 m = match_datetime_icalendar(value) 107 if m: 108 year, month, day, hour, minute, second = map(m.group, [ 109 "year", "month", "day", "hour", "minute", "second" 110 ]) 111 112 if hour and minute and second: 113 dt = datetime( 114 int(year), int(month), int(day), int(hour), int(minute), int(second) 115 ) 116 117 # Impose the indicated timezone. 118 # NOTE: This needs an ambiguity policy for DST changes. 119 120 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 121 122 # Permit dates even if the VALUE is not set to DATE. 123 124 if not attr or attr.get("VALUE") in (None, "DATE"): 125 m = match_date_icalendar(value) 126 if m: 127 year, month, day = map(m.group, ["year", "month", "day"]) 128 return date(int(year), int(month), int(day)) 129 130 return None 131 132 def get_start_of_day(dt, tzid): 133 134 """ 135 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 136 to obtain a datetime in the appropriate time zone. Where time zone 137 transitions occur within a day, the zone of 'dt' may not be the eventual 138 zone of the returned object. 139 """ 140 141 start = datetime(dt.year, dt.month, dt.day, 0, 0) 142 return to_timezone(start, tzid) 143 144 def get_end_of_day(dt, tzid): 145 146 """ 147 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 148 to obtain a datetime in the appropriate time zone. Where time zone 149 transitions occur within a day, the zone of 'dt' may not be the eventual 150 zone of the returned object. 151 """ 152 153 return get_start_of_day(dt + timedelta(1), tzid) 154 155 def get_start_of_next_day(dt, tzid): 156 157 """ 158 Get the start of the day after the day in which 'dt' is positioned. This 159 function is intended to extend either dates or datetimes to the end of a 160 day for the purpose of generating a missing end date or datetime for an 161 event. 162 163 If 'dt' is a date and not a datetime, a plain date object for the next day 164 will be returned. 165 166 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 167 appropriate time zone. Where time zone transitions occur within a day, the 168 zone of 'dt' may not be the eventual zone of the returned object. 169 """ 170 171 if isinstance(dt, datetime): 172 return get_end_of_day(dt, tzid) 173 else: 174 return dt + timedelta(1) 175 176 def ends_on_same_day(dt, end, tzid): 177 178 """ 179 Return whether 'dt' ends on the same day as 'end', testing the date 180 components of 'dt' and 'end' against each other, but also testing whether 181 'end' is the actual end of the day in which 'dt' is positioned. 182 183 Since time zone transitions may occur within a day, 'tzid' is required to 184 determine the end of the day in which 'dt' is positioned, using the zone 185 appropriate at that point in time, not necessarily the zone applying to 186 'dt'. 187 """ 188 189 return ( 190 dt.date() == end.date() or 191 end == get_end_of_day(dt, tzid) 192 ) 193 194 def get_timestamp(): 195 196 "Return the current time as an iCalendar-compatible string." 197 198 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 199 200 def next_date(s): 201 202 """ 203 Return an iCalendar-compatible string indicating the next date after the one 204 provided by 's'. 205 """ 206 207 return format_datetime(get_datetime(s) + timedelta(1)) 208 209 # vim: tabstop=4 expandtab shiftwidth=4