Repository: PrefectHQ/prefect

Issue:
Explore complex schedules
- `UnionSchedule`: combines multiple schedules into 1
- `BusinessDaySchedule`: runs an IntervalSchedule but only on weekdays
- replace schedules entirely with the Python `schedule` library -- this would require serialization?


Discussion:
Just an FYI:

For something like `WeekDaySchedule` (which we could provide as a convenience), the following cron string works:
```
schedule = CronSchedule('0 0 * * 1-5')
[d.day_of_week for d in schedule.next(10)]
[1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
```
TIL

Resolving patch:
diff --git a/src/prefect/schedules.py b/src/prefect/schedules.py
--- a/src/prefect/schedules.py
+++ b/src/prefect/schedules.py
@@ -233,3 +233,93 @@ def __init__(self, start_date: datetime):
         super().__init__(
             start_date=start_date, interval=timedelta(days=1), end_date=start_date
         )
+
+
+class UnionSchedule(Schedule):
+    """
+    A schedule formed by combining multiple other schedules.
+
+    Both `start_date` and `end_date` are inferred as the min / max (resp.) of
+    all provided schedules.  Note that the schedules are not required to all
+    be from the same timezone.  Only unique dates will be used if multiple
+    overlapping schedules are provided.
+
+    Args:
+        - schedules (List[Schedule]): a list of schedules to combine
+
+    Example:
+        ```python
+        import pendulum
+        from datetime import timedelta
+
+        from prefect.schedules import CronSchedule, IntervalSchedule, UnionSchedule
+
+        cron = CronSchedule("0 * * * *", start_date=pendulum.now("US/Eastern"))
+        cron.next(2)
+        # [DateTime(2019, 5, 15, 19, 0, 0, tzinfo=Timezone('US/Eastern')),
+        # DateTime(2019, 5, 15, 20, 0, 0, tzinfo=Timezone('US/Eastern'))]
+
+        first_cron = cron.next(1)[0]
+        interval = IntervalSchedule(start_date=first_cron.in_timezone("US/Pacific"), interval=timedelta(minutes=30))
+        interval.next(2)
+        # [DateTime(2019, 5, 15, 16, 0, 0, tzinfo=Timezone('US/Pacific')),
+        # DateTime(2019, 5, 15, 16, 30, 0, tzinfo=Timezone('US/Pacific'))]
+
+        union = UnionSchedule([cron, interval])
+        union.next(4)
+        # [DateTime(2019, 5, 15, 19, 0, 0, tzinfo=Timezone('US/Eastern')),
+        # DateTime(2019, 5, 15, 16, 30, 0, tzinfo=Timezone('US/Pacific')),
+        # DateTime(2019, 5, 15, 20, 0, 0, tzinfo=Timezone('US/Eastern')),
+        # DateTime(2019, 5, 15, 17, 30, 0, tzinfo=Timezone('US/Pacific'))]
+        ```
+
+    """
+
+    def __init__(self, schedules: List[Schedule] = None):
+        schedules = schedules or []
+        self.schedules = schedules
+        start_date = min(  # type: ignore
+            [s.start_date for s in schedules if s.start_date is not None], default=None
+        )
+        end_date = max(  # type: ignore
+            [s.end_date for s in schedules if s.end_date is not None], default=None
+        )
+        super().__init__(start_date=start_date, end_date=end_date)
+
+    def next(self, n: int, after: datetime = None) -> List[datetime]:
+        """
+        Retrieve next scheduled dates.
+
+        Args:
+            - n (int): the number of future scheduled dates to return
+            - after (datetime, optional): the first result will be after this date
+
+        Returns:
+            - list: list of next scheduled dates
+        """
+        if after is None:
+            after = pendulum.now("utc")
+
+        assert isinstance(after, datetime)  # mypy assertion
+        assert isinstance(self.start_date, pendulum.DateTime)  # mypy assertion
+
+        after = pendulum.instance(after)
+        dates = []  # type: List[pendulum.DateTime]
+        default = pendulum.instance(datetime.max)
+        candidates = {
+            idx: next(iter(s.next(1, after=after)), default)
+            for idx, s in enumerate(self.schedules)
+        }
+
+        while len(dates) < n:
+            dates.append(min(candidates.values()))
+
+            ## get next date for all dates whose current values agree with the current minimum
+            updates = {
+                i: next(iter(self.schedules[i].next(1, after=dates[-1])), default)
+                for i, val in candidates.items()
+                if val == dates[-1]
+            }
+            candidates.update(updates)
+
+        return dates
diff --git a/src/prefect/serialization/schedule.py b/src/prefect/serialization/schedule.py
--- a/src/prefect/serialization/schedule.py
+++ b/src/prefect/serialization/schedule.py
@@ -1,7 +1,7 @@
 from typing import TYPE_CHECKING, Any
 
 import marshmallow
-from marshmallow import fields
+from marshmallow import fields, post_load
 
 import prefect
 from prefect.utilities.serialization import (
@@ -40,6 +40,21 @@ class Meta:
     start_date = DateTimeTZ(required=True)
 
 
+class UnionScheduleSchema(ObjectSchema):
+    class Meta:
+        object_class = prefect.schedules.UnionSchedule
+
+    start_date = DateTimeTZ(required=True)
+    end_date = DateTimeTZ(allow_none=True)
+    schedules = fields.Nested("ScheduleSchema", many=True)
+
+    @post_load
+    def create_object(self, data: dict) -> prefect.schedules.UnionSchedule:
+        schedules = data.pop("schedules", [])
+        base_obj = super().create_object({"schedules": schedules})
+        return base_obj
+
+
 class ScheduleSchema(OneOfSchema):
     """
     Field that chooses between several nested schemas
@@ -50,4 +65,5 @@ class ScheduleSchema(OneOfSchema):
         "IntervalSchedule": IntervalScheduleSchema,
         "CronSchedule": CronScheduleSchema,
         "OneTimeSchedule": OneTimeScheduleSchema,
+        "UnionSchedule": UnionScheduleSchema,
     }


Test patch:
diff --git a/tests/serialization/test_schedules.py b/tests/serialization/test_schedules.py
--- a/tests/serialization/test_schedules.py
+++ b/tests/serialization/test_schedules.py
@@ -143,3 +143,22 @@ def test_deserialize_schedule_with_overridden_interval():
     with pytest.raises(ValueError) as exc:
         schema.load(serialized)
     assert "Interval can not be less than one minute." in str(exc.value)
+
+
+def test_serialize_union_schedule(cron_schedule, interval_schedule):
+    schedule = schedules.UnionSchedule([cron_schedule, interval_schedule])
+    schema = schemas.UnionScheduleSchema()
+    serialized = schema.dump(schedule)
+    assert serialized["schedules"] == [
+        cron_schedule.serialize(),
+        interval_schedule.serialize(),
+    ]
+
+
+def test_roundtrip_serialization_of_union_schedule(cron_schedule, interval_schedule):
+    schedule = schedules.UnionSchedule([cron_schedule, interval_schedule])
+    schema = schemas.UnionScheduleSchema()
+    new = schema.load(schema.dump(schedule))
+    assert isinstance(new, schedules.UnionSchedule)
+    assert len(new.schedules) == 2
+    assert [type(s) for s in new.schedules] == [type(s) for s in schedule.schedules]
diff --git a/tests/test_schedules.py b/tests/test_schedules.py
--- a/tests/test_schedules.py
+++ b/tests/test_schedules.py
@@ -527,3 +527,69 @@ def test_serialize_interval_schedule_with_end_date(self):
             "interval": 3600000000,
             "__version__": __version__,
         }
+
+
+class TestUnionSchedule:
+    @pytest.fixture
+    def list_of_schedules(self):
+        sd = pendulum.datetime(2000, 1, 1)
+        ed = pendulum.datetime(2010, 1, 1)
+
+        list_sched = []
+        list_sched.append(
+            schedules.IntervalSchedule(
+                interval=timedelta(hours=1), start_date=sd, end_date=ed
+            )
+        )
+
+        sd = pendulum.datetime(2004, 1, 1)
+        ed = pendulum.datetime(2017, 1, 1)
+        list_sched.append(
+            schedules.CronSchedule("0 0 * * *", start_date=sd, end_date=ed)
+        )
+        list_sched.append(schedules.CronSchedule("0 9 * * 1-5"))
+        return list_sched
+
+    def test_initialization(self):
+        s = schedules.UnionSchedule()
+        assert s.start_date is None
+        assert s.end_date is None
+
+    def test_initialization_with_schedules(self, list_of_schedules):
+        s = schedules.UnionSchedule(list_of_schedules)
+        assert s.start_date == pendulum.datetime(2000, 1, 1)
+        assert s.end_date == pendulum.datetime(2017, 1, 1)
+
+    def test_next_n(self):
+        start_date = pendulum.datetime(2018, 1, 1)
+        now = pendulum.now("UTC")
+        s = schedules.IntervalSchedule(start_date, timedelta(days=1))
+        t = schedules.OneTimeSchedule(start_date=now.add(hours=1))
+        main = schedules.UnionSchedule([s, t])
+        assert main.next(2) == [now.add(hours=1), s.next(1)[0]]
+
+    def test_next_n_with_no_next(self):
+        start_date = pendulum.datetime(2018, 1, 1)
+        now = pendulum.now("UTC")
+        s = schedules.IntervalSchedule(start_date, timedelta(days=1))
+        t = schedules.OneTimeSchedule(start_date=now.add(hours=-1))
+        main = schedules.UnionSchedule([s, t])
+        assert main.next(2) == s.next(2)
+
+    def test_next_n_with_repeated_schedule_values(self):
+        start_date = pendulum.datetime(2018, 1, 1)
+        now = pendulum.now("UTC")
+        s = schedules.IntervalSchedule(start_date, timedelta(days=1))
+        main = schedules.UnionSchedule([s, s, s, s])
+        assert main.next(3) == s.next(3)
+
+    def test_next_n_with_different_timezones(self):
+        east = schedules.CronSchedule(
+            "0 9 * * 1-5", start_date=pendulum.parse("2019-03-14", tz="US/Eastern")
+        )
+        west = schedules.CronSchedule(
+            "30 6 * * 1-5", start_date=pendulum.parse("2019-03-14", tz="US/Pacific")
+        )
+        main = schedules.UnionSchedule([east, west])
+        expected = [east.next(1)[0], west.next(1)[0], east.next(2)[1], west.next(2)[1]]
+        assert main.next(4) == expected