Repository: PrefectHQ/prefect

Issue:
prefect.yaml should support is_schedule_active
### 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 searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

The `prefect.yaml` file does not allow a user to set `is_schedule_active` for a given deployment. In some cases users don't want schedules to automatically, for example when deploying to a dev or staging environment.

### Reproduction

```python3
build: ...
push: ...
pull: ...

deployments:
  - name: my-deployment
    schedule:
      interval: 60
    is_schedule_active: false
```


### Error

_No response_

### Versions

```Text
Version:             2.14.9
API version:         0.8.4
Python version:      3.9.18
Git commit:          4fba882c
Built:               Thu, Nov 30, 2023 2:55 PM
OS/Arch:             darwin/arm64
Profile:             personal
Server type:         cloud
```


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/src/prefect/cli/_prompts.py b/src/prefect/cli/_prompts.py
--- a/src/prefect/cli/_prompts.py
+++ b/src/prefect/cli/_prompts.py
@@ -5,7 +5,7 @@
 import shutil
 from datetime import timedelta
 from getpass import GetPassWarning
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
 
 import readchar
 from rich.console import Console, Group
@@ -328,21 +328,27 @@ def prompt_schedule_type(console):
     return selection["type"]
 
 
-def prompt_schedule(console) -> SCHEDULE_TYPES:
+def prompt_schedule(console) -> Tuple[SCHEDULE_TYPES, bool]:
     """
     Prompt the user for a schedule type. Once a schedule type is selected, prompt
     the user for the schedule details and return the schedule.
     """
     schedule_type = prompt_schedule_type(console)
     if schedule_type == "Cron":
-        return prompt_cron_schedule(console)
+        schedule = prompt_cron_schedule(console)
     elif schedule_type == "Interval":
-        return prompt_interval_schedule(console)
+        schedule = prompt_interval_schedule(console)
     elif schedule_type == "RRule":
-        return prompt_rrule_schedule(console)
+        schedule = prompt_rrule_schedule(console)
     else:
         raise Exception("Invalid schedule type")
 
+    is_schedule_active = confirm(
+        "Would you like to activate this schedule?", default=True
+    )
+
+    return (schedule, is_schedule_active)
+
 
 @inject_client
 async def prompt_select_work_pool(
diff --git a/src/prefect/cli/deploy.py b/src/prefect/cli/deploy.py
--- a/src/prefect/cli/deploy.py
+++ b/src/prefect/cli/deploy.py
@@ -440,7 +440,10 @@ async def _run_single_deploy(
 
     deploy_config["parameter_openapi_schema"] = parameter_schema(flow)
 
-    deploy_config["schedule"] = _construct_schedule(deploy_config, ci=ci)
+    (
+        deploy_config["schedule"],
+        deploy_config["is_schedule_active"],
+    ) = _construct_schedule(deploy_config, ci=ci)
 
     # determine work pool
     work_pool_name = get_from_dict(deploy_config, "work_pool.name")
@@ -643,6 +646,7 @@ async def _run_single_deploy(
         work_pool_name=get_from_dict(deploy_config, "work_pool.name"),
         version=deploy_config.get("version"),
         schedule=deploy_config.get("schedule"),
+        is_schedule_active=deploy_config.get("is_schedule_active"),
         enforce_parameter_schema=deploy_config.get("enforce_parameter_schema", False),
         parameter_openapi_schema=deploy_config.get("parameter_openapi_schema").dict(),
         parameters=deploy_config.get("parameters"),
@@ -782,7 +786,7 @@ async def _run_multi_deploy(
 def _construct_schedule(
     deploy_config: Dict,
     ci: bool = False,
-) -> Optional[SCHEDULE_TYPES]:
+) -> Tuple[Optional[SCHEDULE_TYPES], Optional[bool]]:
     """
     Constructs a schedule from a deployment configuration.
 
@@ -799,6 +803,7 @@ def _construct_schedule(
     anchor_date = get_from_dict(deploy_config, "schedule.anchor_date")
     rrule = get_from_dict(deploy_config, "schedule.rrule")
     timezone = get_from_dict(deploy_config, "schedule.timezone")
+    schedule_active = get_from_dict(deploy_config, "schedule.active", True)
 
     if cron:
         cron_kwargs = {"cron": cron, "timezone": timezone}
@@ -832,13 +837,18 @@ def _construct_schedule(
                 console=app.console,
             )
         ):
-            schedule = prompt_schedule(app.console)
+            schedule, schedule_active = prompt_schedule(app.console)
         else:
             schedule = None
+            schedule_active = None
     else:
         schedule = None
+        schedule_active = None
+
+    if schedule_active is None:
+        schedule_active = True
 
-    return schedule
+    return (schedule, schedule_active)
 
 
 def _merge_with_default_deploy_config(deploy_config: Dict):
diff --git a/src/prefect/deployments/base.py b/src/prefect/deployments/base.py
--- a/src/prefect/deployments/base.py
+++ b/src/prefect/deployments/base.py
@@ -416,6 +416,9 @@ def _format_deployment_for_saving_to_prefect_file(
         elif isinstance(deployment["schedule"], BaseModel):
             deployment["schedule"] = deployment["schedule"].dict()
 
+        if "is_schedule_active" in deployment:
+            deployment["schedule"]["active"] = deployment.pop("is_schedule_active")
+
     return deployment
 
 


Test patch:
diff --git a/tests/cli/test_deploy.py b/tests/cli/test_deploy.py
--- a/tests/cli/test_deploy.py
+++ b/tests/cli/test_deploy.py
@@ -2963,6 +2963,35 @@ async def test_deploy_no_schedule_interactive_with_ci_flag(
         )
         assert deployment.schedule is None
 
+    @pytest.mark.usefixtures("project_dir")
+    async def test_deploy_with_inactive_schedule(self, work_pool, prefect_client):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            deploy_config = yaml.safe_load(f)
+
+        deploy_config["deployments"][0]["name"] = "test-name"
+        deploy_config["deployments"][0]["schedule"]["cron"] = "0 4 * * *"
+        deploy_config["deployments"][0]["schedule"]["timezone"] = "America/Chicago"
+        deploy_config["deployments"][0]["schedule"]["active"] = False
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(deploy_config, f)
+
+        result = await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=(
+                f"deploy ./flows/hello.py:my_flow -n test-name --pool {work_pool.name}"
+            ),
+        )
+        assert result.exit_code == 0
+
+        deployment = await prefect_client.read_deployment_by_name(
+            "An important name/test-name"
+        )
+        assert deployment.is_schedule_active is False
+        assert deployment.schedule.cron == "0 4 * * *"
+        assert deployment.schedule.timezone == "America/Chicago"
+
 
 class TestMultiDeploy:
     @pytest.mark.usefixtures("project_dir")
@@ -3016,6 +3045,59 @@ async def test_deploy_all(self, prefect_client, work_pool):
         assert deployment2.name == "test-name-2"
         assert deployment2.work_pool_name == work_pool.name
 
+    @pytest.mark.usefixtures("project_dir")
+    async def test_deploy_all_schedules_remain_inactive(
+        self, prefect_client, work_pool
+    ):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        contents["deployments"] = [
+            {
+                "entrypoint": "./flows/hello.py:my_flow",
+                "name": "test-name-1",
+                "schedule": {"interval": 60.0, "active": True},
+                "work_pool": {"name": work_pool.name},
+            },
+            {
+                "entrypoint": "./flows/hello.py:my_flow",
+                "name": "test-name-2",
+                "schedule": {"interval": 60.0, "active": False},
+                "work_pool": {"name": work_pool.name},
+            },
+        ]
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(contents, f)
+
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy --all",
+            expected_code=0,
+            expected_output_contains=[
+                "An important name/test-name-1",
+                "An important name/test-name-2",
+            ],
+            expected_output_does_not_contain=[
+                "You have passed options to the deploy command, but you are"
+                " creating or updating multiple deployments. These options"
+                " will be ignored."
+            ],
+        )
+
+        deployment1 = await prefect_client.read_deployment_by_name(
+            "An important name/test-name-1"
+        )
+        deployment2 = await prefect_client.read_deployment_by_name(
+            "An important name/test-name-2"
+        )
+
+        assert deployment1.name == "test-name-1"
+        assert deployment1.is_schedule_active is True
+        assert deployment2.name == "test-name-2"
+        assert deployment2.is_schedule_active is False
+
     async def test_deploy_selected_deployments(
         self, project_dir, prefect_client, work_pool
     ):
@@ -4460,6 +4542,8 @@ def test_save_user_inputs_with_interval_schedule(self):
                 # enter interval schedule
                 "3600"
                 + readchar.key.ENTER
+                # accept schedule being active
+                + readchar.key.ENTER
                 +
                 # accept create work pool
                 readchar.key.ENTER
@@ -4494,6 +4578,7 @@ def test_save_user_inputs_with_interval_schedule(self):
         assert config["deployments"][1]["schedule"]["interval"] == 3600
         assert config["deployments"][1]["schedule"]["timezone"] == "UTC"
         assert config["deployments"][1]["schedule"]["anchor_date"] is not None
+        assert config["deployments"][1]["schedule"]["active"]
 
     def test_save_user_inputs_with_cron_schedule(self):
         invoke_and_assert(
@@ -4515,6 +4600,8 @@ def test_save_user_inputs_with_cron_schedule(self):
                 +
                 # accept default timezone
                 readchar.key.ENTER
+                # accept schedule being active
+                + readchar.key.ENTER
                 +
                 # accept create work pool
                 readchar.key.ENTER
@@ -4549,6 +4636,7 @@ def test_save_user_inputs_with_cron_schedule(self):
         assert config["deployments"][1]["schedule"]["cron"] == "* * * * *"
         assert config["deployments"][1]["schedule"]["timezone"] == "UTC"
         assert config["deployments"][1]["schedule"]["day_or"]
+        assert config["deployments"][1]["schedule"]["active"]
 
     def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
         # Set up initial deployment deployment
@@ -4569,6 +4657,8 @@ def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
                 + readchar.key.ENTER
                 # accept default timezone
                 + readchar.key.ENTER
+                # accept schedule being active
+                + readchar.key.ENTER
                 +
                 # accept create work pool
                 readchar.key.ENTER
@@ -4606,6 +4696,7 @@ def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
             "cron": "* * * * *",
             "day_or": True,
             "timezone": "UTC",
+            "active": True,
         }
 
         invoke_and_assert(
@@ -4644,6 +4735,7 @@ def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
             "cron": "* * * * *",
             "day_or": True,
             "timezone": "UTC",
+            "active": True,
         }
 
     def test_deploy_existing_deployment_with_changes_prompts_save(self):
@@ -4748,9 +4840,10 @@ def test_save_user_inputs_with_rrule_schedule(self):
                 # enter rrule schedule
                 "FREQ=MINUTELY"
                 + readchar.key.ENTER
-                +
-                # accept default timezone
-                readchar.key.ENTER
+                # accept schedule being active
+                + readchar.key.ENTER
+                # accept create work pool
+                + readchar.key.ENTER
                 +
                 # accept create work pool
                 readchar.key.ENTER
@@ -4784,6 +4877,7 @@ def test_save_user_inputs_with_rrule_schedule(self):
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
         assert config["deployments"][1]["schedule"]["rrule"] == "FREQ=MINUTELY"
         assert config["deployments"][1]["schedule"]["timezone"] == "UTC"
+        assert config["deployments"][1]["schedule"]["active"]
 
     async def test_save_user_inputs_with_actions(self):
         new_deployment_to_save = {
diff --git a/tests/input/test_run_input.py b/tests/input/test_run_input.py
--- a/tests/input/test_run_input.py
+++ b/tests/input/test_run_input.py
@@ -361,6 +361,10 @@ async def test_send_to_can_set_key_prefix(flow_run):
     assert person.human is True
 
 
+# Since this test relies on timing of the send/receive in the CI environment
+# it can sometimes fail. Marking it as flaky rather than bumping the timeout
+# to avoid slowing down the test suite.
+@pytest.mark.flaky
 async def test_receive(flow_run):
     async def send():
         for city, state in [("New York", "NY"), ("Boston", "MA"), ("Chicago", "IL")]: