Repository: PrefectHQ/prefect

Issue:
Create `prefect task serve` command
A `prefect task serve` command should exist that starts a task server for a task given an entrypoint. This should be equivalent to creating a file that contains a main-guarded `serve(my_task)` invocation.


Discussion:

Resolving patch:
diff --git a/src/prefect/cli/__init__.py b/src/prefect/cli/__init__.py
--- a/src/prefect/cli/__init__.py
+++ b/src/prefect/cli/__init__.py
@@ -21,6 +21,7 @@
 import prefect.cli.global_concurrency_limit
 import prefect.cli.profile
 import prefect.cli.server
+import prefect.cli.task
 import prefect.cli.variable
 import prefect.cli.work_pool
 import prefect.cli.work_queue
diff --git a/src/prefect/cli/task.py b/src/prefect/cli/task.py
new file mode 100644
--- /dev/null
+++ b/src/prefect/cli/task.py
@@ -0,0 +1,50 @@
+from typing import List
+
+import typer
+
+from prefect.cli._types import PrefectTyper
+from prefect.cli._utilities import exit_with_error
+from prefect.cli.root import app
+from prefect.task_server import serve as task_serve
+from prefect.utilities.importtools import import_object
+
+task_app = PrefectTyper(name="task", help="Commands for working with task scheduling.")
+app.add_typer(task_app, aliases=["task"])
+
+
+@task_app.command()
+async def serve(
+    entrypoints: List[str] = typer.Argument(
+        ...,
+        help="The paths to one or more tasks, in the form of `./path/to/file.py:task_func_name`.",
+    ),
+):
+    """
+    Serve the provided tasks so that their runs may be submitted to and
+    executed in the engine.
+
+    Args:
+        entrypoints: List of strings representing the paths to one or more
+            tasks. Each path should be in the format
+            `./path/to/file.py:task_func_name`.
+    """
+    tasks = []
+
+    for entrypoint in entrypoints:
+        if ".py:" not in entrypoint:
+            exit_with_error(
+                (
+                    f"Error: Invalid entrypoint format {entrypoint!r}. It "
+                    "must be of the form `./path/to/file.py:task_func_name`."
+                )
+            )
+
+        try:
+            tasks.append(import_object(entrypoint))
+        except Exception:
+            module, task_name = entrypoint.split(":")
+            exit_with_error(
+                f"Error: {module!r} has no function {task_name!r}.", style="red"
+            )
+
+    await task_serve(*tasks)


Test patch:
diff --git a/tests/cli/test_task.py b/tests/cli/test_task.py
new file mode 100644
--- /dev/null
+++ b/tests/cli/test_task.py
@@ -0,0 +1,82 @@
+from unittest import mock
+
+from prefect import __development_base_path__
+from prefect.testing.cli import invoke_and_assert
+from prefect.utilities.asyncutils import run_sync_in_worker_thread
+
+TEST_PROJECTS_DIR = __development_base_path__ / "tests" / "test-projects"
+
+
+async def test_invalid_entrypoint():
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        [
+            "task",
+            "serve",
+            "file.py",
+        ],
+        expected_code=1,
+        expected_output_contains=[
+            (
+                "Error: Invalid entrypoint format 'file.py'. It "
+                "must be of the form `./path/to/file.py:task_func_name`."
+            )
+        ],
+    )
+
+
+async def test_import_failure():
+    await run_sync_in_worker_thread(
+        invoke_and_assert,
+        [
+            "task",
+            "serve",
+            "file.py:nope_not_a_chance",
+        ],
+        expected_code=1,
+        expected_output_contains=[
+            "Error: 'file.py' has no function 'nope_not_a_chance'."
+        ],
+    )
+
+
+async def test_single_entrypoint():
+    with mock.patch(
+        "prefect.cli.task.task_serve", new_callable=mock.AsyncMock
+    ) as task_serve:
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            [
+                "task",
+                "serve",
+                f"{TEST_PROJECTS_DIR}/tasks/household.py:do_the_dishes",
+            ],
+            expected_code=0,
+        )
+
+        task_serve.assert_awaited_once()
+        served_tasks = task_serve.call_args_list[0][0]
+        assert len(served_tasks) == 1
+        assert served_tasks[0].name == "do_the_dishes"
+
+
+async def test_multiple_entrypoints():
+    with mock.patch(
+        "prefect.cli.task.task_serve", new_callable=mock.AsyncMock
+    ) as task_serve:
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            [
+                "task",
+                "serve",
+                f"{TEST_PROJECTS_DIR}/tasks/household.py:do_the_dishes",
+                f"{TEST_PROJECTS_DIR}/tasks/space.py:spacewalk",
+            ],
+            expected_code=0,
+        )
+
+        task_serve.assert_awaited_once()
+        served_tasks = task_serve.call_args_list[0][0]
+        assert len(served_tasks) == 2
+        assert served_tasks[0].name == "do_the_dishes"
+        assert served_tasks[1].name == "spacewalk"
diff --git a/tests/test-projects/tasks/household.py b/tests/test-projects/tasks/household.py
new file mode 100644
--- /dev/null
+++ b/tests/test-projects/tasks/household.py
@@ -0,0 +1,6 @@
+from prefect import task
+
+
+@task
+def do_the_dishes():
+    return "The dishes are :sparkles: clean!"
diff --git a/tests/test-projects/tasks/space.py b/tests/test-projects/tasks/space.py
new file mode 100644
--- /dev/null
+++ b/tests/test-projects/tasks/space.py
@@ -0,0 +1,6 @@
+from prefect import task
+
+
+@task
+def spacewalk():
+    return "Houston, I have a bad feeling about this mission."