Repository: PrefectHQ/prefect
Pull request: Allow `image` passed into `deploy` to be optional if loading flow from storage
Closes https://github.com/PrefectHQ/prefect/issues/11096

### Example Usage
```python
from prefect import flow
from prefect import deploy

@flow(log_prints=True)
def local_flow():
    print("I'm a locally defined flow!")


if __name__ == "__main__":
    # use an image (`build` and `push` args default `True`)
    local_flow.deploy(
         name="test-remote-flow-deploy",
         work_pool_name="my-cloud-run-push-work-pool",
         image="serinabeana/dev:latest",
         job_variables={"image_pull_policy": "Never"},
     )

    # use remote storage
    deploy(
        flow.from_source(
            source="https://github.com/desertaxle/demo.git",
            entrypoint="flow.py:my_flow",
        ).to_deployment(
            name="test-deploy-remote-flow",
        ),
        work_pool_name="my-cloud-run-push-work-pool",
    )

    # multiple deployments, one using remote storage
    deploy(
        local_flow.to_deployment("my-deployment"),
        flow.from_source(
            source="https://github.com/desertaxle/demo.git",
            entrypoint="flow.py:my_flow",
        ).to_deployment(
            name="test-deploy-remote-flow",
        ),
        work_pool_name="my-cloud-run-push-work-pool",
        image="serinabeana/dev:latest",  # will raise if image not provided here
    )
```

### Checklist
<!-- These boxes may be checked after opening the pull request. -->

- [ ] This pull request references any related issue by including "closes `<link to issue>`"
	- If no issue exists and your change is not a small fix, please [create an issue](https://github.com/PrefectHQ/prefect/issues/new/choose) first.
- [ ] This pull request includes tests or only affects documentation.
- [ ] This pull request includes a label categorizing the change e.g. `fix`, `feature`, `enhancement`, `docs`.
  <!-- If you do not have permission to add a label, a maintainer will add one for you -->

For documentation changes:

- [ ] This pull request includes redirect settings in `netlify.toml` for files that are removed or renamed


Problem statement:
`image` argument is required with `.deploy` even if `build=False`
### 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

Users need to provide an `image` argument to `flow.deploy` - even if not building an image.

```
    async def deploy(
        self,
        name: str,
        work_pool_name: str,
        image: Union[str, DeploymentImage],
        build: bool = True,
```

### Describe the proposed behavior

Make `image` argument optional.

### Example Use

Saves unnecessary typing.

### Additional context

_No response_

Hints from issue discussion:

Commit message: update error msg

Patch to review:
diff --git a/src/prefect/deployments/runner.py b/src/prefect/deployments/runner.py
index ed6c2a3b28da..94ecce3a7316 100644
--- a/src/prefect/deployments/runner.py
+++ b/src/prefect/deployments/runner.py
@@ -692,7 +692,7 @@ def push(self):
 async def deploy(
     *deployments: RunnerDeployment,
     work_pool_name: str,
-    image: Union[str, DeploymentImage],
+    image: Optional[Union[str, DeploymentImage]] = None,
     build: bool = True,
     push: bool = True,
     print_next_steps_message: bool = True,
@@ -747,7 +747,13 @@ def local_flow():
             )
         ```
     """
-    if isinstance(image, str):
+    if not image and not all(d.storage for d in deployments):
+        raise ValueError(
+            "Either an image or remote storage location must be provided when deploying"
+            " a deployment."
+        )
+
+    if image and isinstance(image, str):
         image_name, image_tag = parse_image_tag(image)
         image = DeploymentImage(name=image_name, tag=image_tag)
 
@@ -770,7 +776,7 @@ def local_flow():
         )
 
     console = Console()
-    if build:
+    if image and build:
         with Progress(
             SpinnerColumn(),
             TextColumn(f"Building image {image.reference}..."),
@@ -785,7 +791,7 @@ def local_flow():
                 f"Successfully built image {image.reference!r}", style="green"
             )
 
-    if build and push:
+    if image and build and push:
         with Progress(
             SpinnerColumn(),
             TextColumn("Pushing image..."),
@@ -802,6 +808,7 @@ def local_flow():
 
     deployment_exceptions = []
     deployment_ids = []
+    image_ref = image.reference if image else None
     for deployment in track(
         deployments,
         description="Creating/updating deployments...",
@@ -810,9 +817,7 @@ def local_flow():
     ):
         try:
             deployment_ids.append(
-                await deployment.apply(
-                    image=image.reference, work_pool_name=work_pool_name
-                )
+                await deployment.apply(image=image_ref, work_pool_name=work_pool_name)
             )
         except Exception as exc:
             if len(deployments) == 1:
diff --git a/tests/runner/test_runner.py b/tests/runner/test_runner.py
index e7fe36b0dc61..77f426b2396f 100644
--- a/tests/runner/test_runner.py
+++ b/tests/runner/test_runner.py
@@ -1273,6 +1273,73 @@ async def test_deploy_with_image_string(
             pull=True,
         )
 
+    async def test_deploy_without_image_with_flow_stored_remotely(
+        self,
+        work_pool_with_image_variable,
+    ):
+        deployment_id = await deploy(
+            await (
+                await flow.from_source(
+                    source=MockStorage(), entrypoint="flows.py:test_flow"
+                )
+            ).to_deployment(__file__),
+            work_pool_name=work_pool_with_image_variable.name,
+        )
+
+        assert len(deployment_id) == 1
+
+    async def test_deploy_without_image_or_flow_storage_raises(
+        self,
+        work_pool_with_image_variable,
+    ):
+        with pytest.raises(ValueError):
+            deployment_id = await deploy(
+                await dummy_flow_1.to_deployment(__file__),
+                work_pool_name=work_pool_with_image_variable.name,
+            )
+
+            assert len(deployment_id) == 1
+
+    async def test_deploy_with_image_and_flow_stored_remotely(
+        self,
+        work_pool_with_image_variable,
+        mock_build_image,
+    ):
+        deployment_id = await deploy(
+            await (
+                await flow.from_source(
+                    source=MockStorage(), entrypoint="flows.py:test_flow"
+                )
+            ).to_deployment(__file__),
+            work_pool_name=work_pool_with_image_variable.name,
+            image="test-registry/test-image:test-tag",
+        )
+
+        assert len(deployment_id) == 1
+
+        mock_build_image.assert_called_once_with(
+            tag="test-registry/test-image:test-tag",
+            context=Path.cwd(),
+            pull=True,
+        )
+
+    async def test_deploy_multiple_flows_one_using_storage_one_without_raises_with_no_image(
+        self,
+        work_pool_with_image_variable,
+    ):
+        with pytest.raises(ValueError):
+            deployment_ids = await deploy(
+                await dummy_flow_1.to_deployment(__file__),
+                await (
+                    await flow.from_source(
+                        source=MockStorage(), entrypoint="flows.py:test_flow"
+                    )
+                ).to_deployment(__file__),
+                work_pool_name=work_pool_with_image_variable.name,
+            )
+
+            assert len(deployment_ids) == 1
+
     async def test_deploy_with_image_string_no_tag(
         self,
         mock_build_image: MagicMock,


Code review comments:
diff_hunk: @@ -1273,6 +1273,73 @@ async def test_deploy_with_image_string(
             pull=True,
         )
 
+    async def test_deploy_without_image_with_flow_stored_remotely(
+        self,
+        work_pool_with_image_variable,
+    ):
+        deployment_id = await deploy(
+            await (
+                await flow.from_source(
+                    source=MockStorage(), entrypoint="flows.py:test_flow"
+                )
+            ).to_deployment(__file__),
+            work_pool_name=work_pool_with_image_variable.name,
+        )
+
+        assert len(deployment_id) == 1
+
+    async def test_deploy_without_image_or_flow_storage_raises(
+        self,
+        work_pool_with_image_variable,
+    ):
+        with pytest.raises(ValueError):
+            deployment_id = await deploy(
+                await dummy_flow_1.to_deployment(__file__),
+                work_pool_name=work_pool_with_image_variable.name,
+            )
+
+            assert len(deployment_id) == 1
path: tests/runner/test_runner.py
text: @user1:
```suggestion
```

@author:
woops