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 if not dt: 40 return None 41 elif isinstance(dt, datetime): 42 return to_timezone(dt, "UTC") 43 else: 44 return dt 45 46 def to_timezone(dt, name): 47 try: 48 tz = name and timezone(name) or None 49 except UnknownTimeZoneError: 50 tz = None 51 return to_tz(dt, tz) 52 53 def to_tz(dt, tz): 54 if tz is not None and isinstance(dt, datetime): 55 if not dt.tzinfo: 56 return tz.localize(dt) 57 else: 58 return dt.astimezone(tz) 59 else: 60 return dt 61 62 def format_datetime(dt): 63 if not dt: 64 return None 65 elif isinstance(dt, datetime): 66 if dt.tzname() == "UTC": 67 return dt.strftime("%Y%m%dT%H%M%SZ") 68 else: 69 return dt.strftime("%Y%m%dT%H%M%S") 70 else: 71 return dt.strftime("%Y%m%d") 72 73 def get_datetime_item(dt): 74 if not dt: 75 return None, None 76 value = format_datetime(dt) 77 attr = isinstance(dt, datetime) and {"TZID" : dt.tzname(), "VALUE" : "DATE-TIME"} or {"VALUE" : "DATE"} 78 return value, attr 79 80 def get_datetime(value, attr=None): 81 82 """ 83 Return a datetime object from the given 'value' in iCalendar format, using 84 the 'attr' mapping (if specified) to control the conversion. 85 """ 86 87 if not attr or attr.get("VALUE") in (None, "DATE-TIME"): 88 m = match_datetime_icalendar(value) 89 if m: 90 year, month, day, hour, minute, second = map(m.group, [ 91 "year", "month", "day", "hour", "minute", "second" 92 ]) 93 94 if hour and minute and second: 95 dt = datetime( 96 int(year), int(month), int(day), int(hour), int(minute), int(second) 97 ) 98 99 # Impose the indicated timezone. 100 # NOTE: This needs an ambiguity policy for DST changes. 101 102 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 103 104 # Permit dates even if the VALUE is not set to DATE. 105 106 if not attr or attr.get("VALUE") in (None, "DATE"): 107 m = match_date_icalendar(value) 108 if m: 109 year, month, day = map(m.group, ["year", "month", "day"]) 110 return date(int(year), int(month), int(day)) 111 112 return None 113 114 def get_start_of_day(dt, tzid): 115 116 """ 117 Get the start of the day in which 'dt' is positioned, using the given 'tzid' 118 to obtain a datetime in the appropriate time zone. Where time zone 119 transitions occur within a day, the zone of 'dt' may not be the eventual 120 zone of the returned object. 121 """ 122 123 start = datetime(dt.year, dt.month, dt.day, 0, 0) 124 return to_timezone(start, tzid) 125 126 def get_end_of_day(dt, tzid): 127 128 """ 129 Get the end of the day in which 'dt' is positioned, using the given 'tzid' 130 to obtain a datetime in the appropriate time zone. Where time zone 131 transitions occur within a day, the zone of 'dt' may not be the eventual 132 zone of the returned object. 133 """ 134 135 return get_start_of_day(dt + timedelta(1), tzid) 136 137 def get_start_of_next_day(dt, tzid): 138 139 """ 140 Get the start of the day after the day in which 'dt' is positioned. This 141 function is intended to extend either dates or datetimes to the end of a 142 day for the purpose of generating a missing end date or datetime for an 143 event. 144 145 If 'dt' is a date and not a datetime, a plain date object for the next day 146 will be returned. 147 148 If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the 149 appropriate time zone. Where time zone transitions occur within a day, the 150 zone of 'dt' may not be the eventual zone of the returned object. 151 """ 152 153 if isinstance(dt, datetime): 154 return get_end_of_day(dt, tzid) 155 else: 156 return dt + timedelta(1) 157 158 def ends_on_same_day(dt, end, tzid): 159 160 """ 161 Return whether 'dt' ends on the same day as 'end', testing the date 162 components of 'dt' and 'end' against each other, but also testing whether 163 'end' is the actual end of the day in which 'dt' is positioned. 164 165 Since time zone transitions may occur within a day, 'tzid' is required to 166 determine the end of the day in which 'dt' is positioned, using the zone 167 appropriate at that point in time, not necessarily the zone applying to 168 'dt'. 169 """ 170 171 return ( 172 dt.date() == end.date() or 173 end == get_end_of_day(dt, tzid) 174 ) 175 176 def get_timestamp(): 177 return format_datetime(to_timezone(datetime.utcnow(), "UTC")) 178 179 # vim: tabstop=4 expandtab shiftwidth=4