def coerce_naive_until(cls, rrule): # # RFC5545 specifies that the UNTIL rule part MUST ALWAYS be a date # with UTC time. This is extra work for API implementers because # it requires them to perform DTSTART local -> UTC datetime coercion on # POST and UTC -> DTSTART local coercion on GET. # # This block of code is a departure from the RFC. If you send an # rrule like this to the API (without a Z on the UNTIL): # # DTSTART;TZID=America/New_York:20180502T150000 RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL=20180502T180000 # # ...we'll assume that the naive UNTIL is intended to match the DTSTART # timezone (America/New_York), and so we'll coerce to UTC _for you_ # automatically. # if 'until=' in rrule.lower(): # if DTSTART;TZID= is used, coerce "naive" UNTIL values # to the proper UTC date match_until = re.match( r".*?(?P<until>UNTIL\=[0-9]+T[0-9]+)(?P<utcflag>Z?)", rrule) if not len(match_until.group('utcflag')): # rrule = DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000 # Find the UNTIL=N part of the string # naive_until = UNTIL=20200601T170000 naive_until = match_until.group('until') # What is the DTSTART timezone for: # DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000Z # local_tz = tzfile('/usr/share/zoneinfo/America/New_York') local_tz = dateutil.rrule.rrulestr( rrule.replace(naive_until, naive_until + 'Z'), tzinfos=UTC_TIMEZONES)._dtstart.tzinfo # Make a datetime object with tzinfo=<the DTSTART timezone> # localized_until = datetime.datetime(2020, 6, 1, 17, 0, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York')) localized_until = make_aware( datetime.datetime.strptime( re.sub('^UNTIL=', '', naive_until), "%Y%m%dT%H%M%S"), local_tz) # Coerce the datetime to UTC and format it as a string w/ Zulu format # utc_until = UNTIL=20200601T220000Z utc_until = 'UNTIL=' + localized_until.astimezone( pytz.utc).strftime('%Y%m%dT%H%M%SZ') # rrule was: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000 # rrule is now: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T220000Z rrule = rrule.replace(naive_until, utc_until) return rrule
def rrulestr(cls, rrule, **kwargs): """ Apply our own custom rrule parsing logic. This applies some extensions and limitations to parsing that are specific to our supported implementation. Namely: * python-dateutil doesn't _natively_ support `DTSTART;TZID=`; this function parses out the TZID= component and uses it to produce the `tzinfos` keyword argument to `dateutil.rrule.rrulestr()`. In this way, we translate: DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1 ...into... DTSTART:20180601T120000TZID RRULE:FREQ=DAILY;INTERVAL=1 ...and we pass a hint about the local timezone to dateutil's parser: `dateutil.rrule.rrulestr(rrule, { 'tzinfos': { 'TZID': dateutil.tz.gettz('America/New_York') } })` it's possible that we can remove the custom code that performs this parsing if TZID= gains support in upstream dateutil: https://github.com/dateutil/dateutil/pull/615 * RFC5545 specifies that: if the "DTSTART" property is specified as a date with local time (in our case, TZID=), then the UNTIL rule part MUST also be treated as a date with local time. If the "DTSTART" property is specified as a date with UTC time (with a Z suffix), then the UNTIL rule part MUST be specified as a date with UTC time. this function provides additional parsing to translate RRULES like this: DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T170000 ...into this (under the hood): DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T210000Z """ source_rrule = rrule kwargs['tzinfos'] = {} match = cls.TZID_REGEX.match(rrule) if match is not None: rrule = cls.TZID_REGEX.sub("DTSTART\g<stamp>TZI\g<rrule>", rrule) timezone = gettz(match.group('tzid')) if 'until' in rrule.lower(): # if DTSTART;TZID= is used, coerce "naive" UNTIL values # to the proper UTC date match_until = re.match( ".*?(?P<until>UNTIL\=[0-9]+T[0-9]+)(?P<utcflag>Z?)", rrule) until_date = match_until.group('until').split("=")[1] if len(match_until.group('utcflag')): raise ValueError( six.text_type( _('invalid rrule `{}` includes TZINFO= stanza and UTC-based UNTIL clause' ).format(source_rrule))) # noqa localized = make_aware( datetime.datetime.strptime(until_date, "%Y%m%dT%H%M%S"), timezone) utc = localized.astimezone(pytz.utc).strftime('%Y%m%dT%H%M%SZ') rrule = rrule.replace(match_until.group('until'), 'UNTIL={}'.format(utc)) kwargs['tzinfos']['TZI'] = timezone x = dateutil.rrule.rrulestr(rrule, **kwargs) return x