Repository: dateutil/dateutil (Python)

Issue:
Add support of VALUE=DATE and TZID when parsing EXDATE rule
According to the RFC 5545, the EXDATE grammar is:


       exdate     = "EXDATE" exdtparam ":" exdtval *("," exdtval) CRLF

       exdtparam  = *(
                  ;
                  ; The following are OPTIONAL,
                  ; but MUST NOT occur more than once.
                  ;
                  (";" "VALUE" "=" ("DATE-TIME" / "DATE")) /
                  ;
                  (";" tzidparam) /
                  ;
                  ; The following is OPTIONAL,
                  ; and MAY occur more than once.
                  ;
                  (";" other-param)
                  ;
                  )

So all the following examples are valid (let's suppose some valid RRULE preceed the exception set here):

     EXDATE:20170705T143000
     EXDATE;VALUE=DATE-TIME:20170705T143000
     EXDATE;VALUE=DATE:20170705
     EXDATE;TZID=Europe/Paris:20170705T143000
     EXDATE;VALUE=DATE-TIME;TZID=Europe/Paris:20170705T143000

Yet, dateutil only supports the two first formats. Indeed, DATE and TZID params are marked as unsupported (see `_parse_rfc` in rrule.py). Digging into the code of dateutil, I don't see any use of the `parms` list except to check list of param name given in the recurrence rule. However this change might be expanded to other properties, such as DTSTART, as discussed in https://github.com/dateutil/dateutil/issues/401?



Hints:

Patch:
diff --git a/dateutil/rrule.py b/dateutil/rrule.py
--- a/dateutil/rrule.py
+++ b/dateutil/rrule.py
@@ -435,7 +435,7 @@ def __init__(self, freq, dtstart=None,
         if not dtstart:
             if until and until.tzinfo:
                 dtstart = datetime.datetime.now(tz=until.tzinfo).replace(microsecond=0)
-            else:           
+            else:
                 dtstart = datetime.datetime.now().replace(microsecond=0)
         elif not isinstance(dtstart, datetime.datetime):
             dtstart = datetime.datetime.fromordinal(dtstart.toordinal())
@@ -1412,6 +1412,8 @@ def _iter(self):
         self._len = total
 
 
+
+
 class _rrulestr(object):
     """ Parses a string representation of a recurrence rule or set of
     recurrence rules.
@@ -1557,6 +1559,58 @@ def _parse_rfc_rrule(self, line,
                 raise ValueError("invalid '%s': %s" % (name, value))
         return rrule(dtstart=dtstart, cache=cache, **rrkwargs)
 
+    def _parse_date_value(self, date_value, parms, rule_tzids,
+                          ignoretz, tzids, tzinfos):
+        global parser
+        if not parser:
+            from dateutil import parser
+
+        datevals = []
+        value_found = False
+        TZID = None
+
+        for parm in parms:
+            if parm.startswith("TZID="):
+                try:
+                    tzkey = rule_tzids[parm.split('TZID=')[-1]]
+                except KeyError:
+                    continue
+                if tzids is None:
+                    from . import tz
+                    tzlookup = tz.gettz
+                elif callable(tzids):
+                    tzlookup = tzids
+                else:
+                    tzlookup = getattr(tzids, 'get', None)
+                    if tzlookup is None:
+                        msg = ('tzids must be a callable, mapping, or None, '
+                               'not %s' % tzids)
+                        raise ValueError(msg)
+
+                TZID = tzlookup(tzkey)
+                continue
+
+            # RFC 5445 3.8.2.4: The VALUE parameter is optional, but may be found
+            # only once.
+            if parm not in {"VALUE=DATE-TIME", "VALUE=DATE"}:
+                raise ValueError("unsupported parm: " + parm)
+            else:
+                if value_found:
+                    msg = ("Duplicate value parameter found in: " + parm)
+                    raise ValueError(msg)
+                value_found = True
+
+        for datestr in date_value.split(','):
+            date = parser.parse(datestr, ignoretz=ignoretz, tzinfos=tzinfos)
+            if TZID is not None:
+                if date.tzinfo is None:
+                    date = date.replace(tzinfo=TZID)
+                else:
+                    raise ValueError('DTSTART/EXDATE specifies multiple timezone')
+            datevals.append(date)
+
+        return datevals
+
     def _parse_rfc(self, s,
                    dtstart=None,
                    cache=False,
@@ -1629,54 +1683,18 @@ def _parse_rfc(self, s,
                         raise ValueError("unsupported EXRULE parm: "+parm)
                     exrulevals.append(value)
                 elif name == "EXDATE":
-                    for parm in parms:
-                        if parm != "VALUE=DATE-TIME":
-                            raise ValueError("unsupported EXDATE parm: "+parm)
-                    exdatevals.append(value)
+                    exdatevals.extend(
+                        self._parse_date_value(value, parms,
+                                               TZID_NAMES, ignoretz,
+                                               tzids, tzinfos)
+                    )
                 elif name == "DTSTART":
-                    # RFC 5445 3.8.2.4: The VALUE parameter is optional, but
-                    # may be found only once.
-                    value_found = False
-                    TZID = None
-                    valid_values = {"VALUE=DATE-TIME", "VALUE=DATE"}
-                    for parm in parms:
-                        if parm.startswith("TZID="):
-                            try:
-                                tzkey = TZID_NAMES[parm.split('TZID=')[-1]]
-                            except KeyError:
-                                continue
-                            if tzids is None:
-                                from . import tz
-                                tzlookup = tz.gettz
-                            elif callable(tzids):
-                                tzlookup = tzids
-                            else:
-                                tzlookup = getattr(tzids, 'get', None)
-                                if tzlookup is None:
-                                    msg = ('tzids must be a callable, ' +
-                                           'mapping, or None, ' +
-                                           'not %s' % tzids)
-                                    raise ValueError(msg)
-
-                            TZID = tzlookup(tzkey)
-                            continue
-                        if parm not in valid_values:
-                            raise ValueError("unsupported DTSTART parm: "+parm)
-                        else:
-                            if value_found:
-                                msg = ("Duplicate value parameter found in " +
-                                       "DTSTART: " + parm)
-                                raise ValueError(msg)
-                            value_found = True
-                    if not parser:
-                        from dateutil import parser
-                    dtstart = parser.parse(value, ignoretz=ignoretz,
-                                           tzinfos=tzinfos)
-                    if TZID is not None:
-                        if dtstart.tzinfo is None:
-                            dtstart = dtstart.replace(tzinfo=TZID)
-                        else:
-                            raise ValueError('DTSTART specifies multiple timezones')
+                    dtvals = self._parse_date_value(value, parms, TZID_NAMES,
+                                                    ignoretz, tzids, tzinfos)
+                    if len(dtvals) != 1:
+                        raise ValueError("Multiple DTSTART values specified:" +
+                                         value)
+                    dtstart = dtvals[0]
                 else:
                     raise ValueError("unsupported property: "+name)
             if (forceset or len(rrulevals) > 1 or rdatevals
@@ -1698,10 +1716,7 @@ def _parse_rfc(self, s,
                                                       ignoretz=ignoretz,
                                                       tzinfos=tzinfos))
                 for value in exdatevals:
-                    for datestr in value.split(','):
-                        rset.exdate(parser.parse(datestr,
-                                                 ignoretz=ignoretz,
-                                                 tzinfos=tzinfos))
+                    rset.exdate(value)
                 if compatible and dtstart:
                     rset.rdate(dtstart)
                 return rset


Test patch:
diff --git a/dateutil/test/test_rrule.py b/dateutil/test/test_rrule.py
--- a/dateutil/test/test_rrule.py
+++ b/dateutil/test/test_rrule.py
@@ -2853,6 +2853,74 @@ def testStrSetExDate(self):
                           datetime(1997, 9, 9, 9, 0),
                           datetime(1997, 9, 16, 9, 0)])
 
+    def testStrSetExDateMultiple(self):
+        rrstr = ("DTSTART:19970902T090000\n"
+                 "RRULE:FREQ=YEARLY;COUNT=6;BYDAY=TU,TH\n"
+                 "EXDATE:19970904T090000,19970911T090000,19970918T090000\n")
+
+        rr = rrulestr(rrstr)
+        assert list(rr) == [datetime(1997, 9, 2, 9, 0),
+                            datetime(1997, 9, 9, 9, 0),
+                            datetime(1997, 9, 16, 9, 0)]
+
+    def testStrSetExDateWithTZID(self):
+        BXL = tz.gettz('Europe/Brussels')
+        rr = rrulestr("DTSTART;TZID=Europe/Brussels:19970902T090000\n"
+                      "RRULE:FREQ=YEARLY;COUNT=6;BYDAY=TU,TH\n"
+                      "EXDATE;TZID=Europe/Brussels:19970904T090000\n"
+                      "EXDATE;TZID=Europe/Brussels:19970911T090000\n"
+                      "EXDATE;TZID=Europe/Brussels:19970918T090000\n")
+
+        assert list(rr) == [datetime(1997, 9, 2, 9, 0, tzinfo=BXL),
+                            datetime(1997, 9, 9, 9, 0, tzinfo=BXL),
+                            datetime(1997, 9, 16, 9, 0, tzinfo=BXL)]
+
+    def testStrSetExDateValueDateTimeNoTZID(self):
+        rrstr = '\n'.join([
+            "DTSTART:19970902T090000",
+            "RRULE:FREQ=YEARLY;COUNT=4;BYDAY=TU,TH",
+            "EXDATE;VALUE=DATE-TIME:19970902T090000",
+            "EXDATE;VALUE=DATE-TIME:19970909T090000",
+        ])
+
+        rr = rrulestr(rrstr)
+        assert list(rr) == [datetime(1997, 9, 4, 9), datetime(1997, 9, 11, 9)]
+
+    def testStrSetExDateValueMixDateTimeNoTZID(self):
+        rrstr = '\n'.join([
+            "DTSTART:19970902T090000",
+            "RRULE:FREQ=YEARLY;COUNT=4;BYDAY=TU,TH",
+            "EXDATE;VALUE=DATE-TIME:19970902T090000",
+            "EXDATE:19970909T090000",
+        ])
+
+        rr = rrulestr(rrstr)
+        assert list(rr) == [datetime(1997, 9, 4, 9), datetime(1997, 9, 11, 9)]
+
+    def testStrSetExDateValueDateTimeWithTZID(self):
+        BXL = tz.gettz('Europe/Brussels')
+        rrstr = '\n'.join([
+            "DTSTART;VALUE=DATE-TIME;TZID=Europe/Brussels:19970902T090000",
+            "RRULE:FREQ=YEARLY;COUNT=4;BYDAY=TU,TH",
+            "EXDATE;VALUE=DATE-TIME;TZID=Europe/Brussels:19970902T090000",
+            "EXDATE;VALUE=DATE-TIME;TZID=Europe/Brussels:19970909T090000",
+        ])
+
+        rr = rrulestr(rrstr)
+        assert list(rr) == [datetime(1997, 9, 4, 9, tzinfo=BXL),
+                            datetime(1997, 9, 11, 9, tzinfo=BXL)]
+
+    def testStrSetExDateValueDate(self):
+        rrstr = '\n'.join([
+            "DTSTART;VALUE=DATE:19970902",
+            "RRULE:FREQ=YEARLY;COUNT=4;BYDAY=TU,TH",
+            "EXDATE;VALUE=DATE:19970902",
+            "EXDATE;VALUE=DATE:19970909",
+        ])
+
+        rr = rrulestr(rrstr)
+        assert list(rr) == [datetime(1997, 9, 4), datetime(1997, 9, 11)]
+
     def testStrSetDateAndExDate(self):
         self.assertEqual(list(rrulestr(
                               "DTSTART:19970902T090000\n"
@@ -2929,6 +2997,11 @@ def testStrValueDate(self):
         self.assertEqual(list(rr), [datetime(1997, 9, 2, 0, 0, 0),
                                     datetime(1998, 9, 2, 0, 0, 0)])
 
+    def testStrMultipleDTStartComma(self):
+        with pytest.raises(ValueError):
+            rr = rrulestr("DTSTART:19970101T000000,19970202T000000\n"
+                          "RRULE:FREQ=YEARLY;COUNT=1")
+
     def testStrInvalidUntil(self):
         with self.assertRaises(ValueError):
             list(rrulestr("DTSTART:19970902T090000\n"