Repository: PrefectHQ/prefect

Issue:
Prompt for available flows in current directory and subdirectories in `prefect deploy` interactive CLI
When a user runs `prefect deploy` with no entrypoint argument, or if there isn't an entrypoint defined in the current deployment configuration, the user should be presented with a list of flows residing in the current directory and subdirectories. Selecting one of these flows should populate the created deployment with the entrypoint of that flow.


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
@@ -2,6 +2,8 @@
 Utilities for prompting the user for input
 """
 from datetime import timedelta
+from prefect.deployments.base import _search_for_flow_functions
+from prefect.flows import load_flow_from_entrypoint
 from rich.prompt import PromptBase, InvalidResponse
 from rich.text import Text
 
@@ -100,17 +102,23 @@ def build_table() -> Table:
         return table
 
     with Live(build_table(), auto_refresh=False, console=console) as live:
-        live.console.print(
+        instructions_message = (
             f"[bold][green]?[/] {prompt} [bright_blue][Use arrows to move; enter to"
-            " select][/]"
+            " select"
         )
+        if opt_out_message:
+            instructions_message += "; n to select none"
+        instructions_message += "]"
+        live.console.print(instructions_message)
         while selected_row is None:
             key = readchar.readkey()
 
             if key == readchar.key.UP:
                 current_idx = current_idx - 1
                 # wrap to bottom if at the top
-                if current_idx < 0:
+                if opt_out_message and current_idx < 0:
+                    current_idx = len(data)
+                elif not opt_out_message and current_idx < 0:
                     current_idx = len(data) - 1
             elif key == readchar.key.DOWN:
                 current_idx = current_idx + 1
@@ -127,6 +135,8 @@ def build_table() -> Table:
                     return opt_out_response
                 else:
                     selected_row = data[current_idx]
+            elif key == "n" and opt_out_message:
+                return opt_out_response
 
             live.update(build_table(), refresh=True)
 
@@ -374,3 +384,60 @@ async def prompt_create_work_pool(
     )
     console.print(f"Your work pool {work_pool.name!r} has been created!", style="green")
     return work_pool
+
+
+class EntrypointPrompt(PromptBase[str]):
+    response_type = str
+    validate_error_message = "[prompt.invalid]Please enter a valid flow entrypoint."
+
+    def process_response(self, value: str) -> str:
+        try:
+            value.rsplit(":", 1)
+        except ValueError:
+            raise InvalidResponse(self.validate_error_message)
+
+        try:
+            load_flow_from_entrypoint(value)
+        except Exception:
+            raise InvalidResponse(
+                f"[prompt.invalid]Failed to load flow from entrypoint {value!r}."
+                f" {self.validate_error_message}"
+            )
+        return value
+
+
+async def prompt_entrypoint(console: Console) -> str:
+    """
+    Prompt the user for a flow entrypoint. Will search for flow functions in the
+    current working directory and nested subdirectories to prompt the user to select
+    from a list of discovered flows. If no flows are found, the user will be prompted
+    to enter a flow entrypoint manually.
+    """
+    discovered_flows = await _search_for_flow_functions()
+    if not discovered_flows:
+        return EntrypointPrompt.ask(
+            (
+                "[bold][green]?[/] Flow entrypoint (expected format"
+                " path/to/file.py:function_name)"
+            ),
+            console=console,
+        )
+    selected_flow = prompt_select_from_table(
+        console,
+        prompt="Select a flow to deploy",
+        columns=[
+            {"header": "Flow Name", "key": "flow_name"},
+            {"header": "Location", "key": "filepath"},
+        ],
+        data=discovered_flows,
+        opt_out_message="Enter a flow entrypoint manually",
+    )
+    if selected_flow is None:
+        return EntrypointPrompt.ask(
+            (
+                "[bold][green]?[/] Flow entrypoint (expected format"
+                " path/to/file.py:function_name)"
+            ),
+            console=console,
+        )
+    return f"{selected_flow['filepath']}:{selected_flow['function_name']}"
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
@@ -21,6 +21,7 @@
     prompt_select_from_table,
     prompt_schedule,
     prompt_select_work_pool,
+    prompt_entrypoint,
 )
 from prefect.cli.root import app, is_interactive
 from prefect.client.schemas.schedules import (
@@ -296,15 +297,17 @@ async def _run_single_deploy(
         raise ValueError("Only one schedule type can be provided.")
 
     if not deploy_config.get("flow_name") and not deploy_config.get("entrypoint"):
-        raise ValueError(
-            "An entrypoint or flow name must be provided.\n\nDeploy a flow by"
-            " entrypoint:\n\n\t[yellow]prefect deploy"
-            " path/to/file.py:flow_function[/]\n\nDeploy a flow by"
-            " name:\n\n\t[yellow]prefect project register-flow"
-            " path/to/file.py:flow_function\n\tprefect deploy --flow"
-            " registered-flow-name[/]\n\nYou can also provide an entrypoint or flow"
-            " name in this project's prefect.yaml file."
-        )
+        if not is_interactive() and not ci:
+            raise ValueError(
+                "An entrypoint or flow name must be provided.\n\nDeploy a flow by"
+                " entrypoint:\n\n\t[yellow]prefect deploy"
+                " path/to/file.py:flow_function[/]\n\nDeploy a flow by"
+                " name:\n\n\t[yellow]prefect project register-flow"
+                " path/to/file.py:flow_function\n\tprefect deploy --flow"
+                " registered-flow-name[/]\n\nYou can also provide an entrypoint or flow"
+                " name in this project's prefect.yaml file."
+            )
+        deploy_config["entrypoint"] = await prompt_entrypoint(app.console)
     if deploy_config.get("flow_name") and deploy_config.get("entrypoint"):
         raise ValueError(
             "Received an entrypoint and a flow name for this deployment. Please provide"
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
@@ -5,6 +5,8 @@
 To get started, follow along with [the project tutorial](/tutorials/projects/).
 """
 from copy import deepcopy
+import ast
+import asyncio
 import json
 import os
 import subprocess
@@ -16,10 +18,13 @@
 import yaml
 from ruamel.yaml import YAML
 
+import anyio
 from prefect.flows import load_flow_from_entrypoint
 from prefect.utilities.asyncutils import run_sync_in_worker_thread
 from prefect.utilities.filesystem import create_default_ignore_file
 from prefect.utilities.templating import apply_values
+from prefect.settings import PREFECT_DEBUG_MODE
+from prefect.logging import get_logger
 
 from prefect.client.schemas.schedules import IntervalSchedule
 
@@ -439,3 +444,87 @@ def _save_deployment_to_prefect_file(
 
         with prefect_file.open(mode="w") as f:
             ryaml.dump(parsed_prefect_file_contents, f)
+
+
+async def _find_flow_functions_in_file(filename: str) -> List[Dict]:
+    decorator_name = "flow"
+    decorator_module = "prefect"
+    decorated_functions = []
+    async with await anyio.open_file(filename) as f:
+        try:
+            tree = ast.parse(await f.read())
+        except SyntaxError:
+            if PREFECT_DEBUG_MODE:
+                get_logger().debug(
+                    f"Could not parse {filename} as a Python file. Skipping."
+                )
+            return decorated_functions
+
+    for node in ast.walk(tree):
+        if isinstance(node, ast.FunctionDef):
+            for decorator in node.decorator_list:
+                # handles @flow
+                is_name_match = (
+                    isinstance(decorator, ast.Name) and decorator.id == decorator_name
+                )
+                # handles @flow()
+                is_func_name_match = (
+                    isinstance(decorator, ast.Call)
+                    and isinstance(decorator.func, ast.Name)
+                    and decorator.func.id == decorator_name
+                )
+                # handles @prefect.flow
+                is_module_attribute_match = (
+                    isinstance(decorator, ast.Attribute)
+                    and decorator.value.id == decorator_module
+                    and decorator.attr == decorator_name
+                )
+                # handles @prefect.flow()
+                is_module_attribute_func_match = (
+                    isinstance(decorator, ast.Call)
+                    and isinstance(decorator.func, ast.Attribute)
+                    and decorator.func.attr == decorator_name
+                    and isinstance(decorator.func.value, ast.Name)
+                    and decorator.func.value.id == decorator_module
+                )
+                if is_name_match or is_module_attribute_match:
+                    decorated_functions.append(
+                        {
+                            "flow_name": node.name,
+                            "function_name": node.name,
+                            "filepath": str(filename),
+                        }
+                    )
+                if is_func_name_match or is_module_attribute_func_match:
+                    name_kwarg_node = next(
+                        (kw for kw in decorator.keywords if kw.arg == "name"), None
+                    )
+                    flow_name = (
+                        name_kwarg_node.value.value
+                        if isinstance(name_kwarg_node, ast.Constant)
+                        else node.name
+                    )
+                    decorated_functions.append(
+                        {
+                            "flow_name": flow_name,
+                            "function_name": node.name,
+                            "filepath": str(filename),
+                        }
+                    )
+    return decorated_functions
+
+
+async def _search_for_flow_functions(directory: str = "."):
+    """
+    Search for flow functions in the provided directory. If no directory is provided,
+    the current working directory is used.
+
+    Returns:
+        List[Dict]: the flow name, function name, and filepath of all flow functions found
+    """
+    path = anyio.Path(directory)
+    coros = []
+    async for file in path.rglob("*.py"):
+        coros.append(_find_flow_functions_in_file(file))
+
+    return [fn for file_fns in await asyncio.gather(*coros) for fn in file_fns]


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
@@ -477,7 +477,11 @@ async def test_migrate_from_deployment_yaml_to_prefect_yaml(self, work_pool):
         await run_sync_in_worker_thread(
             invoke_and_assert,
             command="deploy",
-            user_input="y" + readchar.key.ENTER + "n" + readchar.key.ENTER,
+            user_input="y"
+            + readchar.key.ENTER
+            + readchar.key.ENTER
+            + "n"
+            + readchar.key.ENTER,
             expected_code=0,
         )
 
@@ -2824,3 +2828,179 @@ 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"
+
+
+@pytest.mark.usefixtures("project_dir", "interactive_console", "work_pool")
+class TestDeployWithoutEntrypoint:
+    async def test_deploy_without_entrypoint(self, prefect_client: PrefectClient):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy",
+            user_input=(
+                # Accept first flow
+                readchar.key.ENTER
+                +
+                # Accept default deployment name
+                readchar.key.ENTER
+                +
+                # decline schedule
+                "n"
+                + readchar.key.ENTER
+                +
+                # accept first work pool
+                readchar.key.ENTER
+                +
+                # decline save user inputs
+                "n"
+                + readchar.key.ENTER
+            ),
+            expected_code=0,
+            expected_output_contains=[
+                "Select a flow to deploy",
+                "test_flow",
+                "import-project/my_module/flow.py",
+                "prod_flow",
+                "import-project/my_module/flow.py",
+                "foobar",
+                "nested-project/implicit_relative.py",
+                "nested-project/explicit_relative.py",
+                "my_flow",
+                "flows/hello.py",
+                "successfully created",
+            ],
+        )
+
+    async def test_deploy_without_entrypoint_manually_enter(
+        self, prefect_client: PrefectClient
+    ):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy",
+            user_input=(
+                # Decline selecting from list
+                "n"
+                +
+                # Enter entrypoint
+                "flows/hello.py:my_flow"
+                + readchar.key.ENTER
+                +
+                # Accept default deployment name
+                readchar.key.ENTER
+                +
+                # decline schedule
+                "n"
+                + readchar.key.ENTER
+                +
+                # accept first work pool
+                readchar.key.ENTER
+                +
+                # decline save user inputs
+                "n"
+                + readchar.key.ENTER
+            ),
+            expected_code=0,
+            expected_output_contains=[
+                "Select a flow to deploy",
+                "Flow entrypoint (expected format path/to/file.py:function_name)",
+                "Deployment 'An important name/default' successfully created",
+            ],
+        )
+
+        deployment = await prefect_client.read_deployment_by_name(
+            name="An important name/default"
+        )
+        assert deployment.entrypoint == "flows/hello.py:my_flow"
+
+    async def test_deploy_validates_manually_entered_entrypoints(
+        self, prefect_client: PrefectClient
+    ):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy",
+            user_input=(
+                # Decline selecting from list
+                "n"
+                +
+                # Enter syntactically invalid entrypoint
+                "flows/hello.py"
+                + readchar.key.ENTER
+                +
+                # Enter entrypoint with non-existent file
+                "flows/does_not_exist.py:my_flow"
+                + readchar.key.ENTER
+                +
+                # Enter entrypoint with non-existent function
+                "flows/hello.py:does_not_exist"
+                + readchar.key.ENTER
+                +
+                # Enter valid entrypoint
+                "flows/hello.py:my_flow"
+                + readchar.key.ENTER
+                +
+                # Accept default deployment name
+                readchar.key.ENTER
+                +
+                # decline schedule
+                "n"
+                + readchar.key.ENTER
+                +
+                # accept first work pool
+                readchar.key.ENTER
+                +
+                # decline save user inputs
+                "n"
+                + readchar.key.ENTER
+            ),
+            expected_code=0,
+            expected_output_contains=[
+                "Select a flow to deploy",
+                "Please enter a valid flow entrypoint.",
+                "Failed to load flow from entrypoint 'flows/does_not_exist.py:my_flow'",
+                "Failed to load flow from entrypoint 'flows/hello.py:does_not_exist'",
+                "Deployment 'An important name/default' successfully created",
+            ],
+        )
+
+        deployment = await prefect_client.read_deployment_by_name(
+            name="An important name/default"
+        )
+        assert deployment.entrypoint == "flows/hello.py:my_flow"
+
+    async def test_deploy_without_entrypoint_no_flows_found(
+        self, prefect_client: PrefectClient
+    ):
+        Path("test_nested_folder").mkdir()
+        os.chdir("test_nested_folder")
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy",
+            user_input=(
+                # Enter valid entrypoint from sibling directory
+                "../flows/hello.py:my_flow"
+                + readchar.key.ENTER
+                +
+                # Accept default deployment name
+                readchar.key.ENTER
+                +
+                # decline schedule
+                "n"
+                + readchar.key.ENTER
+                +
+                # accept first work pool
+                readchar.key.ENTER
+                +
+                # decline save user inputs
+                "n"
+                + readchar.key.ENTER
+            ),
+            expected_code=0,
+            expected_output_contains=[
+                "Flow entrypoint (expected format path/to/file.py:function_name)",
+                "Deployment 'An important name/default' successfully created",
+            ],
+        )
+
+        deployment = await prefect_client.read_deployment_by_name(
+            name="An important name/default"
+        )
+        assert deployment.entrypoint == "../flows/hello.py:my_flow"
diff --git a/tests/deployment/test_base.py b/tests/deployment/test_base.py
--- a/tests/deployment/test_base.py
+++ b/tests/deployment/test_base.py
@@ -13,6 +13,7 @@
     find_prefect_directory,
     initialize_project,
     register_flow,
+    _search_for_flow_functions,
 )
 
 TEST_PROJECTS_DIR = prefect.__development_base_path__ / "tests" / "test-projects"
@@ -213,3 +214,44 @@ async def test_register_flow_disallows_overwrites(self, project_dir):
             force=True,
         )
         assert f.name == "test"
+
+
+class TestDiscoverFlows:
+    async def test_find_all_flows_in_dir_tree(self, project_dir):
+        flows = await _search_for_flow_functions(str(project_dir))
+        assert len(flows) == 5
+        assert sorted(flows, key=lambda f: f["function_name"]) == [
+            {
+                "flow_name": "foobar",
+                "function_name": "foobar",
+                "filepath": str(
+                    project_dir / "nested-project" / "implicit_relative.py"
+                ),
+            },
+            {
+                "flow_name": "foobar",
+                "function_name": "foobar",
+                "filepath": str(
+                    project_dir / "nested-project" / "explicit_relative.py"
+                ),
+            },
+            {
+                "flow_name": "my_flow",
+                "function_name": "my_flow",
+                "filepath": str(project_dir / "flows" / "hello.py"),
+            },
+            {
+                "flow_name": "prod_flow",
+                "function_name": "prod_flow",
+                "filepath": str(
+                    project_dir / "import-project" / "my_module" / "flow.py"
+                ),
+            },
+            {
+                "flow_name": "test_flow",
+                "function_name": "test_flow",
+                "filepath": str(
+                    project_dir / "import-project" / "my_module" / "flow.py"
+                ),
+            },
+        ]
diff --git a/tests/test-projects/import-project/my_module/flow.py b/tests/test-projects/import-project/my_module/flow.py
--- a/tests/test-projects/import-project/my_module/flow.py
+++ b/tests/test-projects/import-project/my_module/flow.py
@@ -1,13 +1,13 @@
-from prefect import flow
+import prefect
 
 from .utils import get_output
 
 
-@flow(name="test")
+@prefect.flow(name="test")
 def test_flow():
     return get_output()
 
 
-@flow(name="test")
+@prefect.flow(name="test")
 def prod_flow():
     return get_output()
diff --git a/tests/test-projects/nested-project/implicit_relative.py b/tests/test-projects/nested-project/implicit_relative.py
--- a/tests/test-projects/nested-project/implicit_relative.py
+++ b/tests/test-projects/nested-project/implicit_relative.py
@@ -1,9 +1,9 @@
 from shared_libs.bar import bar
 from shared_libs.foo import foo
 
-from prefect import flow
+import prefect
 
 
-@flow
+@prefect.flow
 def foobar():
     return foo() + bar()