Repository: PrefectHQ/prefect

Issue:
Add scheduled start time to the `prefect deployment run` CLI
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

The CLI command: `prefect deployment run my_flow/my_deployment` currently triggers a run immediately.

### Describe the proposed behavior

It would be great to add a `--start-time` flag to schedule a run for later (the format might be ISO 8601 string):

```
prefect deployment run my_flow/my_deployment --start-time '2022-09-23T20:00:00+02:00'
```

### Example Use

make it aligned with UI: https://discourse.prefect.io/t/how-to-schedule-a-flow-run-from-a-deployment-for-a-specific-date/1654 

### Additional context

_No response_


Discussion:
This is my current workaround fwiw:

```
from prefect.orion.schemas.states import Scheduled

response = await client.create_flow_run_from_deployment(
            deployment_id=depl_id, state=Scheduled(scheduled_time=scheduled_time)
        )
```
thanks, Taylor, the linked Discourse topic uses the same syntax

Resolving patch:
diff --git a/src/prefect/cli/deployment.py b/src/prefect/cli/deployment.py
--- a/src/prefect/cli/deployment.py
+++ b/src/prefect/cli/deployment.py
@@ -4,11 +4,13 @@
 import json
 import sys
 import textwrap
-from datetime import timedelta
+import warnings
+from datetime import datetime, timedelta
 from enum import Enum
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Union
 
+import dateparser
 import pendulum
 import typer
 import yaml
@@ -19,9 +21,8 @@
 from prefect.cli._types import PrefectTyper
 from prefect.cli._utilities import exit_with_error, exit_with_success
 from prefect.cli.root import app
-from prefect.client import get_client
-from prefect.client.orion import OrionClient
-from prefect.context import PrefectObjectRegistry
+from prefect.client.orion import OrionClient, get_client
+from prefect.context import PrefectObjectRegistry, registry_from_script
 from prefect.deployments import Deployment, load_deployments_from_yaml
 from prefect.exceptions import (
     ObjectAlreadyExists,
@@ -39,6 +40,7 @@
     RRuleSchedule,
 )
 from prefect.settings import PREFECT_UI_URL
+from prefect.states import Scheduled
 from prefect.utilities.asyncutils import run_sync_in_worker_thread
 from prefect.utilities.collections import listrepr
 from prefect.utilities.dispatch import get_registry_for_type, lookup_type
@@ -369,14 +371,23 @@ async def run(
             "parameters passed with `--param` will take precedence over these values."
         ),
     ),
+    start_in: Optional[str] = typer.Option(
+        None,
+        "--start-in",
+    ),
+    start_at: Optional[str] = typer.Option(
+        None,
+        "--start-at",
+    ),
 ):
     """
     Create a flow run for the given flow and deployment.
 
-    The flow run will be scheduled for now and an agent must execute it.
-
+    The flow run will be scheduled to run immediately unless `--start-in` or `--start-at` is specified.
     The flow run will not execute until an agent starts.
     """
+    now = pendulum.now("UTC")
+
     multi_params = {}
     if multiparams:
         if multiparams == "-":
@@ -398,6 +409,47 @@ async def run(
         )
     parameters = {**multi_params, **cli_params}
 
+    if start_in and start_at:
+        exit_with_error(
+            "Only one of `--start-in` or `--start-at` can be set, not both."
+        )
+
+    elif start_in is None and start_at is None:
+        scheduled_start_time = now
+        human_dt_diff = " (now)"
+    else:
+        if start_in:
+            start_time_raw = "in " + start_in
+        else:
+            start_time_raw = "at " + start_at
+        with warnings.catch_warnings():
+            # PyTZ throws a warning based on dateparser usage of the library
+            # See https://github.com/scrapinghub/dateparser/issues/1089
+            warnings.filterwarnings("ignore", module="dateparser")
+
+            try:
+
+                start_time_parsed = dateparser.parse(
+                    start_time_raw,
+                    settings={
+                        "TO_TIMEZONE": "UTC",
+                        "RETURN_AS_TIMEZONE_AWARE": False,
+                        "PREFER_DATES_FROM": "future",
+                        "RELATIVE_BASE": datetime.fromtimestamp(now.timestamp()),
+                    },
+                )
+
+            except Exception as exc:
+                exit_with_error(f"Failed to parse '{start_time_raw!r}': {exc!s}")
+
+        if start_time_parsed is None:
+            exit_with_error(f"Unable to parse scheduled start time {start_time_raw!r}.")
+
+        scheduled_start_time = pendulum.instance(start_time_parsed)
+        human_dt_diff = (
+            " (" + pendulum.format_diff(scheduled_start_time.diff(now)) + ")"
+        )
+
     async with get_client() as client:
         deployment = await get_deployment(client, name, deployment_id)
         flow = await client.read_flow(deployment.flow_id)
@@ -425,7 +477,9 @@ async def run(
         )
 
         flow_run = await client.create_flow_run_from_deployment(
-            deployment.id, parameters=parameters
+            deployment.id,
+            parameters=parameters,
+            state=Scheduled(scheduled_time=scheduled_start_time),
         )
 
     if PREFECT_UI_URL:
@@ -433,12 +487,21 @@ async def run(
     else:
         run_url = "<no dashboard available>"
 
+    datetime_local_tz = scheduled_start_time.in_tz(pendulum.tz.local_timezone())
+    scheduled_display = (
+        datetime_local_tz.to_datetime_string()
+        + " "
+        + datetime_local_tz.tzname()
+        + human_dt_diff
+    )
+
     app.console.print(f"Created flow run {flow_run.name!r}.")
     app.console.print(
         textwrap.dedent(
             f"""
         └── UUID: {flow_run.id}
         └── Parameters: {flow_run.parameters}
+        └── Scheduled start time: {scheduled_display}
         └── URL: {run_url}
         """
         ).strip()


Test patch:
diff --git a/tests/cli/deployment/test_deployment_run.py b/tests/cli/deployment/test_deployment_run.py
new file mode 100644
--- /dev/null
+++ b/tests/cli/deployment/test_deployment_run.py
@@ -0,0 +1,406 @@
+import pendulum
+import pytest
+from pendulum.datetime import DateTime
+from pendulum.duration import Duration
+
+import prefect
+from prefect.testing.cli import invoke_and_assert
+from prefect.utilities.asyncutils import run_sync_in_worker_thread
+
+
+@pytest.fixture
+async def deployment_name(deployment, orion_client):
+    flow = await orion_client.read_flow(deployment.flow_id)
+    return f"{flow.name}/{deployment.name}"
+
+
+@pytest.fixture
+def frozen_now(monkeypatch):
+    now = pendulum.now()
+    monkeypatch.setattr("pendulum.now", lambda *_: now)
+    yield now
+
+
+async def test_run_deployment_only_creates_one_flow_run(
+    deployment_name: str, orion_client: prefect.OrionClient, deployment
+):
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+        ],
+    )
+
+    flow_runs = await orion_client.read_flow_runs()
+    assert len(flow_runs) == 1
+    flow_run = flow_runs[0]
+
+    assert flow_run.deployment_id == deployment.id
+
+
+def test_both_start_in_and_start_at_raises():
+    invoke_and_assert(
+        command=["deployment", "run", "--start-in", "foo", "--start-at", "bar"],
+        expected_code=1,
+        expected_output="Only one of `--start-in` or `--start-at` can be set, not both.",
+    )
+
+
+@pytest.mark.parametrize(
+    "start_at,expected_output",
+    [
+        ("foobar", "Unable to parse scheduled start time 'at foobar'."),
+        ("1671483897", "Unable to parse scheduled start time 'at 1671483897'."),
+        ("Jan 32nd 2023", "Unable to parse scheduled start time 'at Jan 32nd 2023'."),
+        ("Jan 31st 20231", "Unable to parse scheduled start time 'at Jan 31st 20231'."),
+        ("Octob 1st 2020", "Unable to parse scheduled start time 'at Octob 1st 2020'."),
+    ],
+)
+def test_start_at_option_invalid_input(
+    deployment_name: str, start_at: str, expected_output: str
+):
+    invoke_and_assert(
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-at",
+            start_at,
+        ],
+        expected_code=1,
+        expected_output=expected_output,
+    )
+
+
+@pytest.mark.parametrize(
+    "start_at,expected_start_time",
+    [
+        ("12-1-2022 5pm", "2022-12-01 17:00:00"),
+        ("1/1/30", "2030-01-01 00:00:00"),
+        ("13/1/2020 13:31", "2020-01-13 13:31:00"),
+        ("9pm December 31st 2022", "2022-12-31 21:00:00"),
+        ("January 2nd 2023", "2023-01-02 00:00:00"),
+    ],
+)
+async def test_start_at_option_displays_scheduled_start_time(
+    deployment_name: str,
+    start_at: str,
+    expected_start_time: str,
+):
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-at",
+            start_at,
+        ],
+        expected_output_contains=["Scheduled start time:", expected_start_time],
+    )
+
+
+@pytest.mark.parametrize(
+    "start_at,expected_start_time",
+    [
+        ("5-17-23 5:30pm UTC", pendulum.parse("2023-05-17T17:30:00")),
+        ("5-20-2020 5:30pm EDT", pendulum.parse("2020-05-20T17:30:00", tz="EST5EDT")),
+        ("01/31/23 5:30 CST", pendulum.parse("2023-01-31T05:30:00", tz="CST6CDT")),
+        ("5-20-23 5:30pm PDT", pendulum.parse("2023-05-20T17:30:00", tz="PST8PDT")),
+        ("01/31/23 5:30 PST", pendulum.parse("2023-01-31T05:30:00", tz="PST8PDT")),
+    ],
+)
+async def test_start_at_option_with_tz_displays_scheduled_start_time(
+    deployment_name: str, start_at: str, expected_start_time: DateTime
+):
+    expected_start_time_local = expected_start_time.in_tz(pendulum.tz.local_timezone())
+    expected_display = (
+        expected_start_time_local.to_datetime_string()
+        + " "
+        + expected_start_time_local.tzname()
+    )
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-at",
+            start_at,
+        ],
+        expected_output_contains=["Scheduled start time:", expected_display],
+    )
+
+
+@pytest.mark.parametrize(
+    "start_at,expected_start_time",
+    [
+        (
+            "12/20/2022 1am",
+            DateTime(2022, 12, 20, 1, 0, 0, tzinfo=pendulum.tz.local_timezone()),
+        ),
+        (
+            "1-1-2020",
+            DateTime(2020, 1, 1, 0, 0, 0, tzinfo=pendulum.tz.local_timezone()),
+        ),
+        (
+            "5 June 2015",
+            DateTime(2015, 6, 5, 0, 0, 0, tzinfo=pendulum.tz.local_timezone()),
+        ),
+    ],
+)
+async def test_start_at_option_schedules_flow_run(
+    deployment_name: str,
+    start_at: str,
+    expected_start_time: DateTime,
+    orion_client: prefect.OrionClient,
+):
+    expected_display = expected_start_time.to_datetime_string()
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-at",
+            start_at,
+        ],
+        expected_output_contains=f"Scheduled start time: {expected_display}",
+    )
+
+    flow_runs = await orion_client.read_flow_runs()
+    assert len(flow_runs) == 1
+    flow_run = flow_runs[0]
+
+    assert flow_run.state.is_scheduled()
+    scheduled_time = flow_run.state.state_details.scheduled_time
+
+    assert scheduled_time == expected_start_time
+
+
+@pytest.mark.parametrize(
+    "start_at,expected_start_time",
+    [
+        ("5-17-23 5:30pm UTC", pendulum.parse("2023-05-17T17:30:00")),
+        ("5-20-2020 5:30pm EDT", pendulum.parse("2020-05-20T17:30:00", tz="EST5EDT")),
+        ("01/31/23 5:30 CST", pendulum.parse("2023-01-31T05:30:00", tz="CST6CDT")),
+        ("5-20-23 5:30pm PDT", pendulum.parse("2023-05-20T17:30:00", tz="PST8PDT")),
+        ("01/31/23 5:30 PST", pendulum.parse("2023-01-31T05:30:00", tz="PST8PDT")),
+    ],
+)
+async def test_start_at_option_with_tz_schedules_flow_run(
+    deployment_name: str,
+    start_at: str,
+    expected_start_time: DateTime,
+    orion_client: prefect.OrionClient,
+):
+    expected_start_time_local = expected_start_time.in_tz(pendulum.tz.local_timezone())
+    expected_display = (
+        expected_start_time_local.to_datetime_string()
+        + " "
+        + expected_start_time_local.tzname()
+    )
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-at",
+            start_at,
+        ],
+        expected_output_contains=f"Scheduled start time: {expected_display}",
+    )
+
+    flow_runs = await orion_client.read_flow_runs()
+    assert len(flow_runs) == 1
+    flow_run = flow_runs[0]
+
+    assert flow_run.state.is_scheduled()
+    scheduled_time = flow_run.state.state_details.scheduled_time
+
+    assert scheduled_time == expected_start_time
+
+
+@pytest.mark.parametrize(
+    "start_in,expected_output",
+    [
+        ("foobar", "Unable to parse scheduled start time 'in foobar'."),
+        ("1671483897", "Unable to parse scheduled start time 'in 1671483897'."),
+        ("Jan 32nd 2023", "Unable to parse scheduled start time 'in Jan 32nd 2023'."),
+        ("Jan 31st 20231", "Unable to parse scheduled start time 'in Jan 31st 20231'."),
+        ("Octob 1st 2020", "Unable to parse scheduled start time 'in Octob 1st 2020'."),
+    ],
+)
+def test_start_in_option_invalid_input(
+    deployment_name: str, start_in: str, expected_output: str
+):
+    invoke_and_assert(
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-in",
+            start_in,
+        ],
+        expected_code=1,
+        expected_output=expected_output,
+    )
+
+
+@pytest.mark.parametrize(
+    "start_in, expected_display",
+    [
+        ("20 minutes", "in 20 minutes"),
+        ("5 days", "in 5 days"),
+        ("3 seconds", "in a few seconds"),
+        (None, "now"),
+        ("1 year and 3 months", "in 1 year"),
+        ("2 weeks & 1 day", "in 2 weeks"),
+        ("27 hours + 4 mins", "in 1 day"),
+    ],
+)
+async def test_start_in_option_displays_scheduled_start_time(
+    deployment_name: str,
+    start_in: str,
+    expected_display: str,
+):
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-in",
+            start_in,
+        ],
+        expected_output_contains=["Scheduled start time:", expected_display],
+    )
+
+
+@pytest.mark.parametrize(
+    "start_in,expected_duration",
+    [
+        ("10 minutes", pendulum.duration(minutes=10)),
+        ("5 days", pendulum.duration(days=5)),
+        ("3 seconds", pendulum.duration(seconds=3)),
+        (None, pendulum.duration(seconds=0)),
+        ("1 year and 3 months", pendulum.duration(years=1, months=3)),
+        ("2 weeks & 1 day", pendulum.duration(weeks=2, days=1)),
+        ("27 hours + 4 mins", pendulum.duration(days=1, hours=3, minutes=4)),
+    ],
+)
+async def test_start_in_option_schedules_flow_run(
+    deployment_name: str,
+    frozen_now: DateTime,
+    orion_client: prefect.OrionClient,
+    start_in: str,
+    expected_duration: Duration,
+):
+    expected_start_time = frozen_now + expected_duration
+    expected_display = expected_start_time.in_tz(
+        pendulum.tz.local_timezone()
+    ).to_datetime_string()
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-in",
+            start_in,
+        ],
+        expected_output_contains=f"Scheduled start time: {expected_display}",
+    )
+
+    flow_runs = await orion_client.read_flow_runs()
+    assert len(flow_runs) == 1
+    flow_run = flow_runs[0]
+
+    assert flow_run.state.is_scheduled()
+    scheduled_time = flow_run.state.state_details.scheduled_time
+
+    assert scheduled_time == expected_start_time
+
+
+@pytest.mark.parametrize(
+    "start_time,expected_start_time",
+    [
+        (
+            "12/20/2030 1am",
+            DateTime(2030, 12, 20, 1, 0, 0, tzinfo=pendulum.tz.local_timezone()),
+        ),
+        (
+            "1-1-2020",
+            DateTime(2020, 1, 1, 0, 0, 0, tzinfo=pendulum.tz.local_timezone()),
+        ),
+        (
+            "5 June 2015",
+            DateTime(2015, 6, 5, 0, 0, 0, tzinfo=pendulum.tz.local_timezone()),
+        ),
+    ],
+)
+async def test_date_as_start_in_option_schedules_flow_run_equal_to_start_at(
+    deployment_name: str,
+    start_time: str,
+    expected_start_time: DateTime,
+    orion_client: prefect.OrionClient,
+):
+    """
+    Passing a date (rather than something like `5 minutes`) as an argument to start_in results in a scheduled flow run,
+    equivalent to passing the same date as a start_at argument.
+
+    Example:
+    `prefect deployment run --start-in 12/20/2022` and
+    `prefect deployment run --start-at 12/20/2022`
+    have the same scheduled start time.
+
+    The result of the design is unintentional and this test documents the observed
+    output.
+    """
+    expected_display = expected_start_time.to_datetime_string()
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-at",
+            start_time,
+        ],
+        expected_output_contains=f"Scheduled start time: {expected_display}",
+    )
+
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        command=[
+            "deployment",
+            "run",
+            deployment_name,
+            "--start-in",
+            start_time,
+        ],
+        expected_output_contains=f"Scheduled start time: {expected_display}",
+    )
+
+    flow_runs = await orion_client.read_flow_runs()
+
+    assert len(flow_runs) == 2
+    start_at_flow_run = flow_runs[0]
+    start_in_flow_run = flow_runs[1]
+
+    assert start_at_flow_run.state.is_scheduled()
+    assert start_in_flow_run.state.is_scheduled()
+    start_at_scheduled_time = start_at_flow_run.state.state_details.scheduled_time
+    start_in_scheduled_time = start_in_flow_run.state.state_details.scheduled_time
+
+    assert start_at_scheduled_time == expected_start_time
+    assert start_in_scheduled_time == expected_start_time