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, fast_forward=True, **kwargs): """ Apply our own custom rrule parsing requirements """ rrule = Schedule.coerce_naive_until(rrule) kwargs['forceset'] = True x = dateutil.rrule.rrulestr(rrule, tzinfos=UTC_TIMEZONES, **kwargs) for r in x._rrule: if r._dtstart and r._dtstart.tzinfo is None: raise ValueError( 'A valid TZID must be provided (e.g., America/New_York)') if (fast_forward and ('MINUTELY' in rrule or 'HOURLY' in rrule) and 'COUNT=' not in rrule): try: first_event = x[0] # If the first event was over a week ago... if (now() - first_event).days > 7: # hourly/minutely rrules with far-past DTSTART values # are *really* slow to precompute # start *from* one week ago to speed things up drastically dtstart = x._rrule[0]._dtstart.strftime(':%Y%m%dT') new_start = ( now() - datetime.timedelta(days=7)).strftime(':%Y%m%dT') new_rrule = rrule.replace(dtstart, new_start) return Schedule.rrulestr(new_rrule, fast_forward=False) except IndexError: pass return x
def as_ical(self, description=None, rrule=None, url=None): """ Returns the occurrence as iCalendar string. """ event = icalendar.Event() event.add("summary", self.title) event.add("dtstart", to_timezone(self.start, UTC)) event.add("dtend", to_timezone(self.end, UTC)) event.add("last-modified", self.modified or self.created or datetime.utcnow()) event["location"] = icalendar.vText(self.location) if description: event["description"] = icalendar.vText(description) if rrule: event["rrule"] = icalendar.vRecur(icalendar.vRecur.from_ical(rrule.replace("RRULE:", ""))) if url: event.add("url", url) cal = icalendar.Calendar() cal.add("prodid", "-//OneGov//onegov.event//") cal.add("version", "2.0") cal.add_component(event) return cal.to_ical()
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