Repository: PrefectHQ/prefect

Issue:
RRule Producing Incorrect Schedule
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I refreshed the page and this issue still occurred.
- [X] I checked if this issue was specific to the browser I was using by testing with a different browser.

### Bug summary

When inputting an RRule `RDATE:20221012T134000Z,20221012T230000Z,20221013T120000Z,20221014T120000Z,20221015T120000Z` RDATE into the deployment.yaml, it results in the UI showcasing 'every year' on the deployments page. The RRule dates are not consistent with the schedule and does not show any future flow runs. 

### Reproduction

- Install Prefect
- Create a generic hello-world flow
- Create a deployment for the flow
- Use RRule of `RDATE:20221012T134000Z,20221012T230000Z,20221013T120000Z,20221014T120000Z,20221015T120000Z`
- Check the UI and see that '_every year_' populates in the deployment schedule
- No future flow runs exist

### Error

- The deployment.yaml breakdown
<img width="754" alt="Screen Shot 2022-10-13 at 9 15 32 AM" src="https://user-images.githubusercontent.com/24235898/195621522-e4e6c149-d66d-46f8-b34a-a69e050b06ae.png">

- Yearly schedule in the UI

![Screen Shot 2022-10-12 at 11 18 01 AM](https://user-images.githubusercontent.com/24235898/195623107-596a07e5-14e9-4feb-81d4-52913ff23e56.png)


- No future runs existing

![Screen Shot 2022-10-12 at 11 17 51 AM](https://user-images.githubusercontent.com/24235898/195623224-7fe25a8f-a2eb-434e-a6ec-7ab5149b0a77.png)




### Browers

- [X] Chrome
- [ ] Firefox
- [X] Safari
- [ ] Edge

### Prefect version

_No response_

### Additional context

- Happens with other iterations of RRule that uses RDATE to schedule


Discussion:
Notably, when the `RDATE`s are in the past, the schedules do seem to populate but seemingly repeat every 5 days (`RDATE`s shouldn't repeat).

It's also worth noting that `dateutil.rrule` supports `RDATE`s:
```py
In [1]: import pendulum

In [2]: import dateutil.rrule

In [3]: print(pendulum.now("UTC"))
2022-10-13T15:13:19.287422+00:00

In [4]: d = dateutil.rrule.rrulestr('RDATE:20221012T134000Z,20221012T230000Z,20221013T120000Z,20221014T120000Z,20221015T120000Z')

In [5]: list(d.xafter(pendulum.now("UTC"), count=1000))
Out[5]: 
[datetime.datetime(2022, 10, 14, 12, 0, tzinfo=tzutc()),
 datetime.datetime(2022, 10, 15, 12, 0, tzinfo=tzutc())]
```

Resolving patch:
diff --git a/src/prefect/orion/schemas/schedules.py b/src/prefect/orion/schemas/schedules.py
--- a/src/prefect/orion/schemas/schedules.py
+++ b/src/prefect/orion/schemas/schedules.py
@@ -382,7 +382,25 @@ def from_rrule(cls, rrule: dateutil.rrule.rrule):
                 timezone = dtstarts[0].tzinfo.name
             else:
                 timezone = "UTC"
-            rruleset_string = "\n".join(str(r) for r in rrule._rrule)
+
+            rruleset_string = ""
+            if rrule._rrule:
+                rruleset_string += "\n".join(str(r) for r in rrule._rrule)
+            if rrule._exrule:
+                rruleset_string += "\n" if rruleset_string else ""
+                rruleset_string += "\n".join(str(r) for r in rrule._exrule).replace(
+                    "RRULE", "EXRULE"
+                )
+            if rrule._rdate:
+                rruleset_string += "\n" if rruleset_string else ""
+                rruleset_string += "RDATE:" + ",".join(
+                    rd.strftime("%Y%m%dT%H%M%SZ") for rd in rrule._rdate
+                )
+            if rrule._exdate:
+                rruleset_string += "\n" if rruleset_string else ""
+                rruleset_string += "EXDATE:" + ",".join(
+                    exd.strftime("%Y%m%dT%H%M%SZ") for exd in rrule._exdate
+                )
             return RRuleSchedule(rrule=rruleset_string, timezone=timezone)
         else:
             raise ValueError(f"Invalid RRule object: {rrule}")
@@ -402,16 +420,43 @@ def to_rrule(self) -> dateutil.rrule.rrule:
                 )
             return rrule.replace(**kwargs)
         elif isinstance(rrule, dateutil.rrule.rruleset):
-            new_rrset = dateutil.rrule.rruleset(cache=True)
             tz = self.timezone
-            for ii, rr in enumerate(rrule._rrule):
+
+            # update rrules
+            localized_rrules = []
+            for rr in rrule._rrule:
                 kwargs = dict(dtstart=rr._dtstart.replace(tzinfo=timezone))
                 if rr._until:
                     kwargs.update(
                         until=rr._until.replace(tzinfo=timezone),
                     )
-                new_rrset.rrule(rr.replace(**kwargs))
-            return new_rrset
+                localized_rrules.append(rr.replace(**kwargs))
+            rrule._rrule = localized_rrules
+
+            # update exrules
+            localized_exrules = []
+            for exr in rrule._exrule:
+                kwargs = dict(dtstart=exr._dtstart.replace(tzinfo=timezone))
+                if exr._until:
+                    kwargs.update(
+                        until=exr._until.replace(tzinfo=timezone),
+                    )
+                localized_exrules.append(exr.replace(**kwargs))
+            rrule._exrule = localized_exrules
+
+            # update rdates
+            localized_rdates = []
+            for rd in rrule._rdate:
+                localized_rdates.append(rd.replace(tzinfo=timezone))
+            rrule._rdate = localized_rdates
+
+            # update exdates
+            localized_exdates = []
+            for exd in rrule._exdate:
+                localized_exdates.append(exd.replace(tzinfo=timezone))
+            rrule._exdate = localized_exdates
+
+            return rrule
 
     @validator("timezone", always=True)
     def valid_timezone(cls, v):


Test patch:
diff --git a/tests/orion/schemas/test_schedules.py b/tests/orion/schemas/test_schedules.py
--- a/tests/orion/schemas/test_schedules.py
+++ b/tests/orion/schemas/test_schedules.py
@@ -2,6 +2,7 @@
 from datetime import timedelta
 from unittest import mock
 
+import dateutil
 import pendulum
 import pytest
 from dateutil import rrule
@@ -658,7 +659,86 @@ async def test_rrule_schedule_handles_complex_rrulesets(self):
         assert len(dates_from_1900) == 3
         assert len(dates_from_2000) == 0
 
-    @pytest.mark.xfail(reason="we currently cannot roundtrip RRuleSchedule objects")
+    async def test_rrule_schedule_preserves_and_localizes_rrules(self):
+        timezone = "America/New_York"
+        s = RRuleSchedule(
+            rrule="DTSTART:19970902T090000\n"
+            "rrule:FREQ=YEARLY;COUNT=2;BYDAY=TU\n"
+            "RRULE:FREQ=YEARLY;COUNT=1;BYDAY=TH\n",
+            timezone=timezone,
+        )
+        expected_tzinfo = dateutil.tz.gettz(timezone)
+        converted_rruleset = s.to_rrule()
+        assert len(converted_rruleset._rrule) == 2
+        assert converted_rruleset._rrule[0]._dtstart.tzinfo == expected_tzinfo
+
+    async def test_rrule_schedule_preserves_and_localizes_exrules(self):
+        timezone = "America/New_York"
+        s = RRuleSchedule(
+            rrule="DTSTART:19970902T090000\n"
+            "EXRULE:FREQ=YEARLY;COUNT=2;BYDAY=TU\n"
+            "RRULE:FREQ=YEARLY;COUNT=1;BYDAY=TH\n",
+            timezone=timezone,
+        )
+        expected_tzinfo = dateutil.tz.gettz(timezone)
+        converted_rruleset = s.to_rrule()
+        assert len(converted_rruleset._rrule) == 1
+        assert len(converted_rruleset._exrule) == 1
+        assert converted_rruleset._exrule[0]._dtstart.tzinfo == expected_tzinfo
+
+    async def test_rrule_schedule_preserves_and_localizes_rdates(self):
+        timezone = "America/New_York"
+        s = RRuleSchedule(
+            rrule="RDATE:20221012T134000Z,20221012T230000Z,20221013T120000Z,20221014T120000Z,20221015T120000Z",
+            timezone=timezone,
+        )
+        expected_tzinfo = dateutil.tz.gettz(timezone)
+        converted_rruleset = s.to_rrule()
+        assert len(converted_rruleset._rdate) == 5
+        assert len(converted_rruleset._exdate) == 0
+        assert all(rd.tzinfo == expected_tzinfo for rd in converted_rruleset._rdate)
+
+    async def test_rrule_schedule_preserves_and_localizes_exdates(self):
+        timezone = "America/New_York"
+        s = RRuleSchedule(
+            rrule="EXDATE:20221012T134000Z,20221012T230000Z,20221013T120000Z,20221014T120000Z,20221015T120000Z",
+            timezone=timezone,
+        )
+        expected_tzinfo = dateutil.tz.gettz(timezone)
+        converted_rruleset = s.to_rrule()
+        assert len(converted_rruleset._rdate) == 0
+        assert len(converted_rruleset._exdate) == 5
+        assert all(rd.tzinfo == expected_tzinfo for rd in converted_rruleset._exdate)
+
+    async def test_serialization_preserves_rrules_rdates_exrules_exdates(self):
+        dt_nyc = datetime(2018, 1, 11, 4, tz="America/New_York")
+        last_leap_year = datetime(2020, 2, 29, tz="America/New_York")
+        next_leap_year = datetime(2024, 2, 29, tz="America/New_York")
+        rrset = rrule.rruleset(cache=True)
+        rrset.rrule(rrule.rrule(rrule.HOURLY, count=10, dtstart=dt_nyc))
+        rrset.exrule(rrule.rrule(rrule.DAILY, count=10, dtstart=dt_nyc))
+        rrset.rdate(last_leap_year)
+        rrset.exdate(next_leap_year)
+
+        expected_tzinfo = dateutil.tz.gettz("America/New_York")
+        serialized_schedule = RRuleSchedule.from_rrule(rrset)
+        roundtrip_rruleset = serialized_schedule.to_rrule()
+
+        # assert string serialization preserves all rruleset components
+        assert len(roundtrip_rruleset._rrule) == 1
+        assert len(roundtrip_rruleset._exrule) == 1
+        assert len(roundtrip_rruleset._rdate) == 1
+        assert len(roundtrip_rruleset._exdate) == 1
+
+        # assert rruleset localizes all rruleset components
+        assert roundtrip_rruleset._rrule[0]._dtstart.tzinfo == expected_tzinfo
+        assert roundtrip_rruleset._exrule[0]._dtstart.tzinfo == expected_tzinfo
+        assert roundtrip_rruleset._rdate[0].tzinfo == expected_tzinfo
+        assert roundtrip_rruleset._exdate[0].tzinfo == expected_tzinfo
+
+    @pytest.mark.xfail(
+        reason="we currently cannot roundtrip RRuleSchedule objects for all timezones"
+    )
     async def test_rrule_schedule_handles_rruleset_roundtrips(self):
         s1 = RRuleSchedule(
             rrule="DTSTART:19970902T090000\n"
@@ -690,7 +770,9 @@ async def test_rrule_schedule_rejects_rrulesets_with_many_dtstarts(self):
         with pytest.raises(ValueError, match="too many dtstarts"):
             s = RRuleSchedule.from_rrule(rrset)
 
-    @pytest.mark.xfail(reason="we currently cannot roundtrip RRuleSchedule objects")
+    @pytest.mark.xfail(
+        reason="we currently cannot roundtrip RRuleSchedule objects for all timezones"
+    )
     async def test_rrule_schedule_handles_rrule_roundtrips(self):
         dt = datetime(2018, 3, 11, 4, tz="Europe/Berlin")
         base_rule = rrule.rrule(rrule.HOURLY, dtstart=dt)