Repository: PrefectHQ/prefect

Issue:
"schedule" not recognised in Deployment.build_from_flow
### 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

When setting a schedule using build_from_flow, the schedule is ignored. 

### Reproduction

```python3
from prefect import flow
from prefect.deployments import Deployment

from prefect.client.schemas.schedules import CronSchedule


@flow(log_prints=True)
def local_flow():
    print("I'm a flow written to test deploy from source")


if __name__ == "__main__":
    deployment = Deployment.build_from_flow(
        name='test-sched',
        flow=local_flow,
        work_queue_name="managed",
        schedule=CronSchedule(cron="0 0 * * *", timezone="America/Chicago"),
    )
    deployment.apply()
```


### Error

```python3
No error.  The schedule deprecation message is still shown.  No schedule shows in the UI.
```


### Versions

```Text
Version:             2.16.3
API version:         0.8.4
Python version:      3.10.4
Git commit:          e3f02c00
Built:               Thu, Mar 7, 2024 4:56 PM
OS/Arch:             darwin/x86_64
Profile:             staging
Server type:         cloud
```


### Additional context

Works as expected on 2.14.21


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
@@ -38,6 +38,7 @@
 from prefect.context import FlowRunContext, PrefectObjectRegistry, TaskRunContext
 from prefect.deployments.schedules import (
     FlexibleScheduleList,
+    create_minimal_deployment_schedule,
     normalize_to_minimal_deployment_schedules,
 )
 from prefect.deployments.steps.core import run_steps
@@ -53,6 +54,7 @@
 from prefect.logging.loggers import flow_run_logger, get_logger
 from prefect.states import Scheduled
 from prefect.tasks import Task
+from prefect.utilities.annotations import NotSet
 from prefect.utilities.asyncutils import run_sync_in_worker_thread, sync_compatible
 from prefect.utilities.callables import ParameterSchema, parameter_schema
 from prefect.utilities.filesystem import relative_path_to_current_platform, tmpchdir
@@ -649,21 +651,27 @@ def validate_deprecated_schedule_fields(cls, values):
             )
         return values
 
-    @validator("schedule")
-    def validate_schedule(cls, value):
-        if value:
-            cls._validate_schedule(value)
-        return value
-
     @root_validator(pre=True)
-    def validate_schedules(cls, values):
-        if "schedules" in values:
-            values["schedules"] = normalize_to_minimal_deployment_schedules(
-                values["schedules"]
-            )
+    def reconcile_schedules(cls, values):
+        schedule = values.get("schedule", NotSet)
+        schedules = values.get("schedules", NotSet)
+
+        if schedules is not NotSet:
+            values["schedules"] = normalize_to_minimal_deployment_schedules(schedules)
+        elif schedule is not NotSet:
+            values["schedule"] = None
+
+            if schedule is None:
+                values["schedules"] = []
+            else:
+                values["schedules"] = [
+                    create_minimal_deployment_schedule(
+                        schedule=schedule, active=values.get("is_schedule_active")
+                    )
+                ]
 
-            for schedule in values["schedules"]:
-                cls._validate_schedule(schedule.schedule)
+        for schedule in values.get("schedules", []):
+            cls._validate_schedule(schedule.schedule)
 
         return values
 
@@ -984,11 +992,17 @@ async def build_from_flow(
 
         # note that `deployment.load` only updates settings that were *not*
         # provided at initialization
-        deployment = cls(
-            name=name,
-            schedules=schedules,
+
+        deployment_args = {
+            "name": name,
+            "flow_name": flow.name,
             **kwargs,
-        )
+        }
+
+        if schedules is not None:
+            deployment_args["schedules"] = schedules
+
+        deployment = cls(**deployment_args)
         deployment.flow_name = flow.name
         if not deployment.entrypoint:
             ## first see if an entrypoint can be determined
diff --git a/src/prefect/deployments/schedules.py b/src/prefect/deployments/schedules.py
--- a/src/prefect/deployments/schedules.py
+++ b/src/prefect/deployments/schedules.py
@@ -3,6 +3,14 @@
 from prefect.client.schemas.objects import MinimalDeploymentSchedule
 from prefect.client.schemas.schedules import SCHEDULE_TYPES
 
+try:
+    from prefect.server.schemas.schedules import SCHEDULE_TYPES as SERVER_SCHEDULE_TYPES
+
+    SERVER_SCHEDULE_TYPES = get_args(SERVER_SCHEDULE_TYPES)
+except ImportError:
+    # `prefect-client` does not have access to the server schemas.
+    SERVER_SCHEDULE_TYPES = ()
+
 FlexibleScheduleList = Sequence[Union[MinimalDeploymentSchedule, dict, SCHEDULE_TYPES]]
 
 
@@ -28,6 +36,11 @@ def normalize_to_minimal_deployment_schedules(
                 normalized.append(create_minimal_deployment_schedule(**obj))
             elif isinstance(obj, MinimalDeploymentSchedule):
                 normalized.append(obj)
+            elif isinstance(obj, SERVER_SCHEDULE_TYPES):
+                raise ValueError(
+                    "Server schema schedules are not supported. Please use "
+                    "the schedule objects from `prefect.client.schemas.schedules`"
+                )
             else:
                 raise ValueError(
                     "Invalid schedule provided. Must be a schedule object, a dict,"


Test patch:
diff --git a/tests/cli/deployment/test_deployment_build.py b/tests/cli/deployment/test_deployment_build.py
--- a/tests/cli/deployment/test_deployment_build.py
+++ b/tests/cli/deployment/test_deployment_build.py
@@ -205,7 +205,7 @@ def test_passing_no_schedule_to_build(self, patch_import, tmp_path):
         )
 
         deployment = Deployment.load_from_yaml(tmp_path / "test.yaml")
-        assert deployment.schedule is None
+        assert deployment.schedules == []
 
     def test_passing_cron_schedules_to_build(self, patch_import, tmp_path):
         invoke_and_assert(
@@ -227,8 +227,9 @@ def test_passing_cron_schedules_to_build(self, patch_import, tmp_path):
         )
 
         deployment = Deployment.load_from_yaml(tmp_path / "test.yaml")
-        assert deployment.schedule.cron == "0 4 * * *"
-        assert deployment.schedule.timezone == "Europe/Berlin"
+        schedule = deployment.schedules[0].schedule
+        assert schedule.cron == "0 4 * * *"
+        assert schedule.timezone == "Europe/Berlin"
 
     def test_passing_interval_schedules_to_build(self, patch_import, tmp_path):
         invoke_and_assert(
@@ -252,9 +253,10 @@ def test_passing_interval_schedules_to_build(self, patch_import, tmp_path):
         )
 
         deployment = Deployment.load_from_yaml(tmp_path / "test.yaml")
-        assert deployment.schedule.interval == timedelta(seconds=42)
-        assert deployment.schedule.anchor_date == pendulum.parse("2040-02-02")
-        assert deployment.schedule.timezone == "America/New_York"
+        schedule = deployment.schedules[0].schedule
+        assert schedule.interval == timedelta(seconds=42)
+        assert schedule.anchor_date == pendulum.parse("2040-02-02")
+        assert schedule.timezone == "America/New_York"
 
     def test_passing_anchor_without_interval_exits(self, patch_import, tmp_path):
         invoke_and_assert(
@@ -294,8 +296,9 @@ def test_parsing_rrule_schedule_string_literal(self, patch_import, tmp_path):
         )
 
         deployment = Deployment.load_from_yaml(tmp_path / "test.yaml")
+        schedule = deployment.schedules[0].schedule
         assert (
-            deployment.schedule.rrule
+            schedule.rrule
             == "DTSTART:20220910T110000\nRRULE:FREQ=HOURLY;BYDAY=MO,TU,WE,TH,FR,SA;BYHOUR=9,10,11,12,13,14,15,16,17"
         )
 
@@ -321,11 +324,12 @@ def test_parsing_rrule_schedule_json(self, patch_import, tmp_path):
         )
 
         deployment = Deployment.load_from_yaml(tmp_path / "test.yaml")
+        schedule = deployment.schedules[0].schedule
         assert (
-            deployment.schedule.rrule
+            schedule.rrule
             == "DTSTART:20220910T110000\nRRULE:FREQ=HOURLY;BYDAY=MO,TU,WE,TH,FR,SA;BYHOUR=9,10,11,12,13,14,15,16,17"
         )
-        assert deployment.schedule.timezone == "America/New_York"
+        assert schedule.timezone == "America/New_York"
 
     def test_parsing_rrule_timezone_overrides_if_passed_explicitly(
         self, patch_import, tmp_path
@@ -353,11 +357,12 @@ def test_parsing_rrule_timezone_overrides_if_passed_explicitly(
         )
 
         deployment = Deployment.load_from_yaml(tmp_path / "test.yaml")
+        schedule = deployment.schedules[0].schedule
         assert (
-            deployment.schedule.rrule
+            schedule.rrule
             == "DTSTART:20220910T110000\nRRULE:FREQ=HOURLY;BYDAY=MO,TU,WE,TH,FR,SA;BYHOUR=9,10,11,12,13,14,15,16,17"
         )
-        assert deployment.schedule.timezone == "Europe/Berlin"
+        assert schedule.timezone == "Europe/Berlin"
 
     @pytest.mark.parametrize(
         "schedules",
@@ -687,7 +692,7 @@ def test_entrypoint_works_with_flow_func_with_underscores(
     ):
         flow_code = """
         from prefect import flow
-        
+
         @flow
         def dog_flow_func():
             pass
diff --git a/tests/deployment/test_schedules.py b/tests/deployment/test_schedules.py
--- a/tests/deployment/test_schedules.py
+++ b/tests/deployment/test_schedules.py
@@ -13,6 +13,7 @@
     create_minimal_deployment_schedule,
     normalize_to_minimal_deployment_schedules,
 )
+from prefect.server.schemas.schedules import CronSchedule as ServerCronSchedule
 
 
 @pytest.mark.parametrize(
@@ -108,6 +109,13 @@ def test_normalize_mixed():
     assert normalized[2].schedule.rrule == "FREQ=DAILY"
 
 
+def test_normalize_server_schema():
+    with pytest.raises(ValueError, match="Server schema schedules are not supported"):
+        normalize_to_minimal_deployment_schedules(
+            schedules=[ServerCronSchedule(cron="0 0 * * *")]
+        )
+
+
 def test_normalize_incompatible():
     with pytest.raises(ValueError, match="Invalid schedule provided"):
         normalize_to_minimal_deployment_schedules(schedules=[1, 2, 3])
diff --git a/tests/test_deployments.py b/tests/test_deployments.py
--- a/tests/test_deployments.py
+++ b/tests/test_deployments.py
@@ -546,6 +546,46 @@ async def test_build_from_flow_set_schedules_shorthand(self, flow_function):
             isinstance(s, MinimalDeploymentSchedule) for s in deployment.schedules
         )
 
+    async def test_build_from_flow_legacy_schedule_supported(
+        self, flow_function, prefect_client
+    ):
+        deployment = await Deployment.build_from_flow(
+            name="legacy_schedule_supported",
+            flow=flow_function,
+            schedule=CronSchedule(cron="2 1 * * *", timezone="America/Chicago"),
+        )
+
+        deployment_id = await deployment.apply()
+
+        refreshed = await prefect_client.read_deployment(deployment_id)
+        assert refreshed.schedule.cron == "2 1 * * *"
+
+    async def test_build_from_flow_clear_schedules_via_legacy_schedule(
+        self, flow_function, prefect_client
+    ):
+        deployment = await Deployment.build_from_flow(
+            name="clear_schedules_via_legacy_schedule",
+            flow=flow_function,
+            schedule=CronSchedule(cron="2 1 * * *", timezone="America/Chicago"),
+        )
+
+        deployment_id = await deployment.apply()
+
+        refreshed = await prefect_client.read_deployment(deployment_id)
+        assert refreshed.schedule.cron == "2 1 * * *"
+
+        deployment = await Deployment.build_from_flow(
+            name="clear_schedules_via_legacy_schedule",
+            flow=flow_function,
+            schedule=None,
+        )
+
+        deployment_id_2 = await deployment.apply()
+        assert deployment_id == deployment_id_2
+
+        refreshed = await prefect_client.read_deployment(deployment_id)
+        assert refreshed.schedule is None
+
 
 class TestYAML:
     def test_deployment_yaml_roundtrip(self, tmp_path):