Repository: PrefectHQ/prefect

Issue:
Allow pattern matching on deployment name in deploy CLI
### 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

The `prefect deploy` command can create deployments defined in a `prefect.yaml` file by referring to one or more deployments by name, e.g. `prefect deploy -n flow-a/default -n flow-b/default`.


### Describe the proposed behavior

I would like to refer to a pattern rather than a single name.

### Example Use

```bash
# Create all deployments for flow-a
prefect deploy -n "flow-a/*"

# Create all deployments named prod
prefect deploy -n "*/prod"

# Create all deployments with extract in the flow name
prefect deploy -n "*extract*/*"
```

### Additional context

_No response_


Discussion:

Resolving patch:
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
@@ -1,6 +1,7 @@
 """Module containing implementation for deploying projects."""
 import json
 import os
+import re
 from copy import deepcopy
 from datetime import timedelta
 from getpass import GetPassWarning
@@ -77,7 +78,14 @@ async def deploy(
         help="DEPRECATED: The name of a registered flow to create a deployment for.",
     ),
     names: List[str] = typer.Option(
-        None, "--name", "-n", help="The name to give the deployment."
+        None,
+        "--name",
+        "-n",
+        help=(
+            "The name to give the deployment. Can be a pattern. Examples:"
+            " 'my-deployment', 'my-flow/my-deployment', 'my-deployment-*',"
+            " '*-flow-name/deployment*'"
+        ),
     ),
     description: str = typer.Option(
         None,
@@ -233,7 +241,16 @@ async def deploy(
     }
     try:
         deploy_configs, actions = _load_deploy_configs_and_actions(ci=ci)
-        deploy_configs = _pick_deploy_configs(deploy_configs, names, deploy_all, ci)
+
+        parsed_names = []
+        for name in names:
+            if "*" in name:
+                parsed_names.extend(_parse_name_from_pattern(deploy_configs, name))
+            else:
+                parsed_names.append(name)
+        deploy_configs = _pick_deploy_configs(
+            deploy_configs, parsed_names, deploy_all, ci
+        )
 
         if len(deploy_configs) > 1:
             if any(options.values()):
@@ -254,7 +271,7 @@ async def deploy(
         else:
             # Accommodate passing in -n flow-name/deployment-name as well as -n deployment-name
             options["names"] = [
-                name.split("/", 1)[-1] if "/" in name else name for name in names
+                name.split("/", 1)[-1] if "/" in name else name for name in parsed_names
             ]
 
             await _run_single_deploy(
@@ -1151,6 +1168,64 @@ def _filter_matching_deploy_config(name, deploy_configs):
     return matching_deployments
 
 
+def _parse_name_from_pattern(deploy_configs, name_pattern):
+    """
+    Parse the deployment names from a user-provided pattern such as "flow-name/*" or "my-deployment-*"
+
+    Example:
+
+    >>> deploy_configs = [
+    ...     {"name": "my-deployment-1", "entrypoint": "flow-name-1"},
+    ...     {"name": "my-deployment-2", "entrypoint": "flow-name-2"},
+    ...     {"name": "my-deployment-3", "entrypoint": "flow-name-3"},
+    ... ]
+
+    >>> _parse_name_from_pattern(deploy_configs, "flow-name-1/*")
+    ["my-deployment-1"]
+
+    Args:
+        deploy_configs: A list of deploy configs
+        name: A pattern to match against the deploy configs
+
+    Returns:
+        List[str]: a list of deployment names that match the given pattern
+    """
+    parsed_names = []
+
+    name_pattern = re.escape(name_pattern).replace(r"\*", ".*")
+
+    # eg. "flow-name/deployment-name"
+    if "/" in name_pattern:
+        flow_name, deploy_name = name_pattern.split("/", 1)
+        flow_name = (
+            re.compile(flow_name.replace("*", ".*"))
+            if "*" in flow_name
+            else re.compile(flow_name)
+        )
+        deploy_name = (
+            re.compile(deploy_name.replace("*", ".*"))
+            if "*" in deploy_name
+            else re.compile(deploy_name)
+        )
+    # e.g. "deployment-name"
+    else:
+        flow_name = None
+        deploy_name = re.compile(name_pattern.replace("*", ".*"))
+
+    for deploy_config in deploy_configs:
+        # skip the default deploy config where this may be None
+        if not deploy_config.get("entrypoint"):
+            continue
+        entrypoint = deploy_config.get("entrypoint").split(":")[-1].replace("_", "-")
+        deployment_name = deploy_config.get("name")
+        flow_match = flow_name.fullmatch(entrypoint) if flow_name else True
+        deploy_match = deploy_name.fullmatch(deployment_name)
+        if flow_match and deploy_match:
+            parsed_names.append(deployment_name)
+
+    return parsed_names
+
+
 def _handle_pick_deploy_with_name(deploy_configs, names, ci=False):
     matched_deploy_configs = []
     deployment_names = []


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
@@ -3005,6 +3005,251 @@ async def test_deploy_existing_deployment_and_nonexistent_deployment_deploys_for
                 "An important name/test-name-3"
             )
 
+
+class TestDeployPattern:
+    @pytest.mark.parametrize(
+        "deploy_name",
+        [
+            ("my-flow/test-name-*", "my-flow-test-name-2"),
+            ("my-f*/test-name-1", "my-f*/test-name-2"),
+            "*-name-*",
+            ("my-*ow/test-name-1", "test-*-2"),
+            ("*-flow/*-name-1", "*-name-2"),
+            "my-flow/t*",
+            ("*/test-name-1", "*/test-name-2"),
+            "*/t*",
+        ],
+    )
+    async def test_pattern_deploy_multiple_existing_deployments(
+        self, deploy_name, project_dir, prefect_client, work_pool
+    ):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        contents["deployments"] = [
+            {
+                "name": "test-name-1",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+            {
+                "name": "test-name-2",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+            {
+                "name": "dont-deploy-me",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+        ]
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(contents, f)
+
+        if isinstance(deploy_name, tuple):
+            deploy_command = "deploy " + " ".join(
+                [f"-n '{name}'" for name in deploy_name]
+            )
+        else:
+            deploy_command = f"deploy -n '{deploy_name}'"
+
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=deploy_command,
+            expected_code=0,
+            expected_output_contains=[
+                "Deploying flows with selected deployment configurations...",
+                "An important name/test-name-1",
+                "An important name/test-name-2",
+            ],
+            expected_output_does_not_contain=[
+                "An important name/dont-deploy-me",
+            ],
+        )
+
+        # Check if the deployment was created correctly
+        deployment1 = await prefect_client.read_deployment_by_name(
+            "An important name/test-name-1"
+        )
+        assert deployment1.name == "test-name-1"
+        assert deployment1.work_pool_name == work_pool.name
+
+        deployment2 = await prefect_client.read_deployment_by_name(
+            "An important name/test-name-2"
+        )
+        assert deployment2.name == "test-name-2"
+        assert deployment2.work_pool_name == work_pool.name
+
+        with pytest.raises(ObjectNotFound):
+            await prefect_client.read_deployment_by_name(
+                "An important name/dont-deploy-me"
+            )
+
+    @pytest.mark.parametrize(
+        "deploy_name",
+        [
+            "*/nonexistent-deployment-name",
+            "my-f*/nonexistent-deployment-name",
+            "nonexistent-deployment-name",
+            "nonexistent-*-name",
+            "nonexistent-flow/*",
+            "nonexistent-*/nonexistent-*",
+        ],
+    )
+    async def test_pattern_deploy_nonexistent_deployments_no_existing_deployments(
+        self, deploy_name, project_dir, prefect_client, work_pool
+    ):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=f"deploy -n '{deploy_name}'",
+            expected_code=1,
+            expected_output_contains=[
+                "An entrypoint must be provided",
+            ],
+        )
+
+    @pytest.mark.parametrize(
+        "deploy_name",
+        [
+            "*/nonexistent-deployment-name",
+            "my-f*/nonexistent-deployment-name",
+            "nonexistent-*-name",
+            "nonexistent-flow/*",
+            "nonexistent-*/nonexistent-*",
+        ],
+    )
+    async def test_pattern_deploy_nonexistent_deployments_with_existing_deployments(
+        self, deploy_name, project_dir, prefect_client, work_pool
+    ):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        contents["deployments"] = [
+            {
+                "name": "test-name-1",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+            {
+                "name": "test-name-2",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+        ]
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(contents, f)
+
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=f"deploy -n '{deploy_name}'",
+            expected_code=1,
+            expected_output_contains=[
+                (
+                    "Discovered one or more deployment configurations, but no name was"
+                    " given. Please specify the name of at least one deployment to"
+                    " create or update."
+                ),
+            ],
+            expected_output_does_not_contain=[
+                "An important name/test-name-1",
+                "An important name/test-name-2",
+            ],
+        )
+
+        # Check if the deployments were not created
+        with pytest.raises(ObjectNotFound):
+            await prefect_client.read_deployment_by_name(
+                "An important name/test-name-1"
+            )
+
+        with pytest.raises(ObjectNotFound):
+            await prefect_client.read_deployment_by_name(
+                "An important name/test-name-2"
+            )
+
+    @pytest.mark.parametrize(
+        "deploy_name",
+        [
+            ("my-flow/test-name-*", "nonexistent-deployment"),
+            ("my-f*/test-name-1", "my-f*/test-name-2", "my-f*/nonexistent-deployment"),
+            ("*-name-4", "*-name-*"),
+            ("my-flow/t*", "nonexistent-flow/*"),
+        ],
+    )
+    async def test_pattern_deploy_one_existing_deployment_one_nonexistent_deployment(
+        self, project_dir, prefect_client, work_pool, deploy_name
+    ):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        contents["deployments"] = [
+            {
+                "name": "test-name-1",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+            {
+                "name": "test-name-2",
+                "entrypoint": "./flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name},
+            },
+            {
+                "name": "dont-deploy-me",
+                "entrypoint": "./flows/hello.py:my_flow",
+            },
+        ]
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(contents, f)
+
+        if isinstance(deploy_name, tuple):
+            deploy_command = "deploy " + " ".join(
+                [f"-n '{name}'" for name in deploy_name]
+            )
+        else:
+            deploy_command = f"deploy -n '{deploy_name}'"
+
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=deploy_command,
+            expected_code=0,
+            expected_output_contains=[
+                "Deploying flows with selected deployment configurations...",
+                "An important name/test-name-1",
+                "An important name/test-name-2",
+            ],
+            expected_output_does_not_contain=[
+                (
+                    "Discovered one or more deployment configurations, but no name was"
+                    " given. Please specify the name of at least one deployment to"
+                    " create or update."
+                ),
+                "An important name/dont-deploy-me",
+            ],
+        )
+
+        # Check if the deployment was created correctly
+        deployment1 = await prefect_client.read_deployment_by_name(
+            "An important name/test-name-1"
+        )
+        assert deployment1.name == "test-name-1"
+        assert deployment1.work_pool_name == work_pool.name
+
+        deployment2 = await prefect_client.read_deployment_by_name(
+            "An important name/test-name-2"
+        )
+        assert deployment2.name == "test-name-2"
+
+        with pytest.raises(ObjectNotFound):
+            await prefect_client.read_deployment_by_name(
+                "An important name/dont-deploy-me"
+            )
+
     @pytest.mark.parametrize(
         "deploy_names",
         [