Repository: PrefectHQ/prefect

Issue:
Deployment.build_from_flow does not clear existing multiple schedules correctly
If you run `Deployment.build_from_flow(..., schedules=[])` for a Deployment that exists and has multiple schedules, one schedule will remain.

This happens because the "schedule" field is now generated on the server to support backward compatibility with older clients, but `Deployment.load` is not aware of the newly generated nature of "schedule." `Deployment.build_from_flow` first loads the existing Deployment, which has `schedule` and `schedules` populated. Because `Deployment` received `schedules=[]` in `__init__`, it ignores the API's version of the `schedules` field, but it sets `schedule`. When `Deployment.apply` sends the data to the server, it includes `schedule`, which the server interprets as a schedule sent from an out-of-date client.

## Bug reproduction
You can reproduce it with this Deployment script:
```
import sys

from prefect import flow
from prefect.client.schemas.actions import DeploymentScheduleCreate
from prefect.client.schemas.schedules import IntervalSchedule
from prefect.deployments import Deployment


@flow
def test_flow():
    print("Hello!")


if __name__ == '__main__':
    if sys.argv[1] == "1":
        deployment = Deployment.build_from_flow(
            test_flow,
            "test-flow-deployment-schedules",
            schedules=[
                DeploymentScheduleCreate(schedule=IntervalSchedule(interval=60)),
                DeploymentScheduleCreate(schedule=IntervalSchedule(interval=120))
            ],
            load_existing=False
        )
        deployment.apply()
    elif sys.argv[1] == "2":
        deployment = Deployment.build_from_flow(
            test_flow,
            "test-flow-deployment-schedules",
            schedules=[],
            load_existing=False
        )
        deployment.apply()
```

Call it like this to create the multi-schedule Deployment and then update the Deployment to, in theory, have zero schedules:
```
$ python bad_deployment.py 1
$ python bad_deployment.py 2
```

If you look at the Deployment's schedules, it will still have one:

```
$ prefect deployment schedule ls test-flow/test-flow-deployment-schedules
                         Deployment Schedules                         
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓
┃ ID                                   ┃ Schedule           ┃ Active ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩
│ 5432cf68-bde8-461c-b9dd-1a0fc3c32f0e │ interval: 0:01:00s │ True   │
└──────────────────────────────────────┴────────────────────┴────────┘

```

Instead of this, you should see zero schedules for this deployment.


Discussion:

Resolving patch:
diff --git a/src/prefect/deployments/deployments.py b/src/prefect/deployments/deployments.py
--- a/src/prefect/deployments/deployments.py
+++ b/src/prefect/deployments/deployments.py
@@ -20,9 +20,9 @@
 from prefect.client.schemas.actions import DeploymentScheduleCreate
 
 if HAS_PYDANTIC_V2:
-    from pydantic.v1 import BaseModel, Field, parse_obj_as, validator
+    from pydantic.v1 import BaseModel, Field, parse_obj_as, root_validator, validator
 else:
-    from pydantic import BaseModel, Field, parse_obj_as, validator
+    from pydantic import BaseModel, Field, parse_obj_as, root_validator, validator
 
 from prefect._internal.compatibility.experimental import experimental_field
 from prefect.blocks.core import Block
@@ -470,6 +470,7 @@ def _yaml_dict(self) -> dict:
 
     @classmethod
     def _validate_schedule(cls, value):
+        """We do not support COUNT-based (# of occurrences) RRule schedules for deployments."""
         if value:
             rrule_value = getattr(value, "rrule", None)
             if rrule_value and "COUNT" in rrule_value.upper():
@@ -491,7 +492,7 @@ def _validate_schedule(cls, value):
         default_factory=list,
         description="One of more tags to apply to this deployment.",
     )
-    schedule: SCHEDULE_TYPES = None
+    schedule: Optional[SCHEDULE_TYPES] = Field(default=None)
     schedules: List[MinimalDeploymentSchedule] = Field(
         default_factory=list,
         description="The schedules to run this deployment on.",
@@ -609,31 +610,30 @@ def validate_automation_names(cls, field_value, values, field, config):
 
         return field_value
 
+    @root_validator(pre=True)
+    def validate_deprecated_schedule_fields(cls, values):
+        if values.get("schedule") and not values.get("schedules"):
+            logger.warning(
+                "The field 'schedule' in 'Deployment' has been deprecated. It will not be "
+                "available after Sep 2024. Define schedules in the `schedules` list instead."
+            )
+        elif values.get("is_schedule_active") and not values.get("schedules"):
+            logger.warning(
+                "The field 'is_schedule_active' in 'Deployment' has been deprecated. It will "
+                "not be available after Sep 2024. Use the `active` flag within a schedule in "
+                "the `schedules` list instead and the `pause` flag in 'Deployment' to pause "
+                "all schedules."
+            )
+        return values
+
     @validator("schedule")
     def validate_schedule(cls, value):
-        """We do not support COUNT-based (# of occurrences) RRule schedules for deployments."""
         if value:
             cls._validate_schedule(value)
-
-        logger.warning(
-            "The field 'schedule' in 'Deployment' has been deprecated. It will not be "
-            "available after Sep 2024. Define schedules in the `schedules` list instead."
-        )
-        return value
-
-    @validator("is_schedule_active")
-    def validate_is_schedule_active(cls, value):
-        logger.warning(
-            "The field 'is_schedule_active' in 'Deployment' has been deprecated. It will "
-            " not be available after Sep 2024. Use the `active` flag within a schedule in "
-            "the `schedules` list instead and the `pause` flag in 'Deployment' to pause "
-            "all schedules."
-        )
         return value
 
     @validator("schedules")
     def validate_schedules(cls, value):
-        """We do not support COUNT-based (# of occurrences) RRule schedules for deployments."""
         for schedule in value:
             cls._validate_schedule(schedule.schedule)
         return value
@@ -692,6 +692,8 @@ async def load(self) -> bool:
                         "triggers",
                         "enforce_parameter_schema",
                         "schedules",
+                        "schedule",
+                        "is_schedule_active",
                     }
                 )
                 for field in set(self.__fields__.keys()) - excluded_fields:
@@ -706,6 +708,23 @@ async def load(self) -> bool:
                         for schedule in deployment.schedules
                     ]
 
+                # The API server generates the "schedule" field from the
+                # current list of schedules, so if the user has locally set
+                # "schedules" to anything, we should avoid sending "schedule"
+                # and let the API server generate a new value if necessary.
+                if "schedules" in self.__fields_set__:
+                    self.schedule = None
+                    self.is_schedule_active = None
+                else:
+                    # The user isn't using "schedules," so we should
+                    # populate "schedule" and "is_schedule_active" from the
+                    # API's version of the deployment, unless the user gave
+                    # us these fields in __init__().
+                    if "schedule" not in self.__fields_set__:
+                        self.schedule = deployment.schedule
+                    if "is_schedule_active" not in self.__fields_set__:
+                        self.is_schedule_active = deployment.is_schedule_active
+
                 if "infrastructure" not in self.__fields_set__:
                     if deployment.infrastructure_document_id:
                         self.infrastructure = Block._from_block_document(


Test patch:
diff --git a/tests/test_deployments.py b/tests/test_deployments.py
--- a/tests/test_deployments.py
+++ b/tests/test_deployments.py
@@ -742,6 +742,50 @@ async def test_deployment_apply_updates_schedules(
         )
         assert dep.schedules[0].active is True
 
+    async def test_deployment_build_from_flow_clears_multiple_schedules(
+        self,
+        patch_import,
+        flow_function,
+        tmp_path,
+        prefect_client,
+    ):
+        d = await Deployment.build_from_flow(
+            flow_function,
+            name="TEST",
+            schedules=[
+                MinimalDeploymentSchedule(
+                    schedule=RRuleSchedule(rrule="FREQ=HOURLY;INTERVAL=1"),
+                    active=True,
+                ),
+                MinimalDeploymentSchedule(
+                    schedule=RRuleSchedule(rrule="FREQ=HOURLY;INTERVAL=60"),
+                    active=True,
+                ),
+            ],
+        )
+        dep_id = await d.apply()
+        dep = await prefect_client.read_deployment(dep_id)
+
+        expected_rrules = {"FREQ=HOURLY;INTERVAL=1", "FREQ=HOURLY;INTERVAL=60"}
+
+        assert dep.schedule
+        assert set([s.schedule.rrule for s in dep.schedules]) == expected_rrules
+
+        # Apply an empty list of schedules to clear schedules.
+        d2 = await Deployment.build_from_flow(
+            flow_function,
+            name="TEST",
+            schedules=[],
+        )
+        await d2.apply()
+        assert d2.schedules == []
+        assert d2.schedule is None
+
+        # Check the API to make sure the schedules are cleared there, too.
+        modified_dep = await prefect_client.read_deployment(dep_id)
+        assert modified_dep.schedules == []
+        assert modified_dep.schedule is None
+
     @pytest.mark.parametrize(
         "provided, expected",
         [(True, True), (False, False), (None, True)],