Repository: PrefectHQ/prefect

Issue:
Add `schedule=True` kwarg to `flow.run`
This keyword will be a blocking command which serves as a "local scheduler" attached to this specific flow.  In particular, `flow.run(schedule=True)` will wait until `flow.schedule.next(1)`, and then execute the flow.  It will then pause, and wait until the next scheduled time, etc. indefinitely until the process dies.  This will allow people to _truly_ build, execute and schedule workflows locally, albeit with none of the fun security / concurrency / etc. guarantees that Cloud provides.


Discussion:

Resolving patch:
diff --git a/src/prefect/core/flow.py b/src/prefect/core/flow.py
--- a/src/prefect/core/flow.py
+++ b/src/prefect/core/flow.py
@@ -5,7 +5,9 @@
 import functools
 import inspect
 import json
+import pendulum
 import tempfile
+import time
 import uuid
 from collections import Counter
 from typing import (
@@ -842,6 +844,7 @@ def run(
         parameters: Dict[str, Any] = None,
         return_tasks: Iterable[Task] = None,
         runner_cls: type = None,
+        on_schedule: bool = False,
         **kwargs: Any
     ) -> "prefect.engine.state.State":
         """
@@ -851,6 +854,8 @@ def run(
             - parameters (Dict[str, Any], optional): values to pass into the runner
             - return_tasks ([Task], optional): list of tasks which return state
             - runner_cls (type): an optional FlowRunner class (will use the default if not provided)
+            - on_schedule (bool, optional): if `True`, this command will block and
+                run this Flow on its schedule indefinitely; note that no stateful execution occurs (e.g., retries)
             - **kwargs: additional keyword arguments; if any provided keywords
                 match known parameter names, they will be used as such. Otherwise they will be passed to the
                 `FlowRunner.run()` method
@@ -858,6 +863,26 @@ def run(
         Returns:
             - State of the flow after it is run resulting from it's return tasks
         """
+        if on_schedule is True:
+            if self.schedule is None:
+                raise ValueError(
+                    "Flow must have a schedule in order to run with `on_schedule=True`"
+                )
+            while True:  # run indefinitely
+                end = self.schedule.next(1)[0]
+                while True:
+                    now = pendulum.now("utc")
+                    dur = (end - now).total_seconds()
+                    if dur <= 0:
+                        break
+                    time.sleep(dur / 2)
+                self.run(
+                    parameters=parameters,
+                    runner_cls=runner_cls,
+                    on_schedule=False,
+                    **kwargs
+                )
+
         if runner_cls is None:
             runner_cls = prefect.engine.get_default_flow_runner_class()
 


Test patch:
diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py
--- a/tests/core/test_flow.py
+++ b/tests/core/test_flow.py
@@ -1,5 +1,6 @@
 import datetime
 import logging
+import pendulum
 import sys
 import tempfile
 import uuid
@@ -1239,3 +1240,36 @@ def test_to_environment_file_writes_data(self):
             f.to_environment_file(tmp.name)
             with open(tmp.name, "r") as f:
                 assert f.read()
+
+
+def test_schedule_kwarg_raises_if_no_schedule():
+    f = Flow()
+    with pytest.raises(ValueError) as exc:
+        f.run(on_schedule=True)
+    assert "must have a schedule" in str(exc.value)
+
+
+def test_schedule_kwarg_runs_on_schedule():
+    class MockSchedule(prefect.schedules.Schedule):
+        call_count = 0
+
+        def next(self, n):
+            if self.call_count < 2:
+                self.call_count += 1
+                return [pendulum.now("utc")]
+            else:
+                raise SyntaxError("Cease scheduling!")
+
+    class StatefulTask(Task):
+        call_count = 0
+
+        def run(self):
+            self.call_count += 1
+
+    t = StatefulTask()
+    schedule = MockSchedule()
+    f = Flow(tasks=[t], schedule=schedule)
+    with pytest.raises(SyntaxError) as exc:
+        f.run(on_schedule=True)
+    assert "Cease" in str(exc.value)
+    assert t.call_count == 2