From 577e500723d5640f2a0ffa85051cda61341a479f Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Tue, 14 Apr 2026 09:21:02 +0200 Subject: [PATCH] Support imported fleets in `dstack fleet get` Support the `/` syntax in `dstack fleet get`. Example: ``` $ dstack fleet get my-project/my-fleet --json ``` --- src/dstack/_internal/cli/commands/fleet.py | 32 +++++++++++++------ src/dstack/_internal/core/models/common.py | 7 +++- .../_internal/core/models/test_common.py | 27 ++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 src/tests/_internal/core/models/test_common.py diff --git a/src/dstack/_internal/cli/commands/fleet.py b/src/dstack/_internal/cli/commands/fleet.py index 4e9e09a3f..c0b4c0e71 100644 --- a/src/dstack/_internal/cli/commands/fleet.py +++ b/src/dstack/_internal/cli/commands/fleet.py @@ -14,6 +14,7 @@ ) from dstack._internal.cli.utils.fleet import get_fleets_table, print_fleets_table from dstack._internal.core.errors import CLIError, ResourceNotExistsError +from dstack._internal.core.models.common import EntityReference from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent @@ -49,6 +50,7 @@ def _register(self): ) delete_parser.add_argument( "name", + type=EntityReference.parse, help="The name of the fleet", ).completer = FleetNameCompleter() # type: ignore[attr-defined] delete_parser.add_argument( @@ -73,6 +75,7 @@ def _register(self): "name", nargs="?", metavar="NAME", + type=EntityReference.parse, help="The name of the fleet", ).completer = FleetNameCompleter() # type: ignore[attr-defined] name_group.add_argument( @@ -112,35 +115,43 @@ def _list(self, args: argparse.Namespace): pass def _delete(self, args: argparse.Namespace): + if args.name.project is not None: + console.print( + "The [code]/[/] format is not supported for fleet names." + " Can only delete fleets or instances owned by the current project" + ) + exit(1) + name = args.name.name + try: - self.api.client.fleets.get(project_name=self.api.project, name=args.name) + self.api.client.fleets.get(project_name=self.api.project, name=name) except ResourceNotExistsError: - console.print(f"Fleet [code]{args.name}[/] does not exist") + console.print(f"Fleet [code]{name}[/] does not exist") exit(1) if not args.instances: - if not args.yes and not confirm_ask(f"Delete the fleet [code]{args.name}[/]?"): + if not args.yes and not confirm_ask(f"Delete the fleet [code]{name}[/]?"): console.print("\nExiting...") return with console.status("Deleting fleet..."): - self.api.client.fleets.delete(project_name=self.api.project, names=[args.name]) + self.api.client.fleets.delete(project_name=self.api.project, names=[name]) - console.print(f"Fleet [code]{args.name}[/] deleted") + console.print(f"Fleet [code]{name}[/] deleted") return if not args.yes and not confirm_ask( - f"Delete the fleet [code]{args.name}[/] instances [code]{args.instances}[/]?" + f"Delete the fleet [code]{name}[/] instances [code]{args.instances}[/]?" ): console.print("\nExiting...") return with console.status("Deleting fleet instances..."): self.api.client.fleets.delete_instances( - project_name=self.api.project, name=args.name, instance_nums=args.instances + project_name=self.api.project, name=name, instance_nums=args.instances ) - console.print(f"Fleet [code]{args.name}[/] instances deleted") + console.print(f"Fleet [code]{name}[/] instances deleted") def _get(self, args: argparse.Namespace): # TODO: Implement non-json output format @@ -157,7 +168,10 @@ def _get(self, args: argparse.Namespace): project_name=self.api.project, fleet_id=fleet_id ) else: - fleet = self.api.client.fleets.get(project_name=self.api.project, name=args.name) + fleet = self.api.client.fleets.get( + project_name=args.name.project or self.api.project, + name=args.name.name, + ) except ResourceNotExistsError: console.print(f"Fleet [code]{args.name or args.id}[/] not found") exit(1) diff --git a/src/dstack/_internal/core/models/common.py b/src/dstack/_internal/core/models/common.py index a3bf68cff..70578e272 100644 --- a/src/dstack/_internal/core/models/common.py +++ b/src/dstack/_internal/core/models/common.py @@ -160,12 +160,17 @@ class EntityReference(CoreModel): def parse(cls, v: Union[str, "EntityReference"]) -> "EntityReference": if isinstance(v, EntityReference): return v + invalid_ref_error = ValueError( + "Invalid entity reference. Only `` or `/` formats are allowed" + ) parts = v.split("/") + if any(len(part) == 0 for part in parts): + raise invalid_ref_error if len(parts) == 1: return cls(project=None, name=parts[0]) if len(parts) == 2: return cls(project=parts[0], name=parts[1]) - raise ValueError("Invalid entity reference. Only `/` format is allowed") + raise invalid_ref_error def format(self) -> str: if self.project is None: diff --git a/src/tests/_internal/core/models/test_common.py b/src/tests/_internal/core/models/test_common.py new file mode 100644 index 000000000..8cc1e5003 --- /dev/null +++ b/src/tests/_internal/core/models/test_common.py @@ -0,0 +1,27 @@ +import pytest + +from dstack._internal.core.models.common import EntityReference + + +class TestEntityReferenceParse: + @pytest.mark.parametrize( + "value, expected", + [ + ("fleet", EntityReference(project=None, name="fleet")), + ("project/fleet", EntityReference(project="project", name="fleet")), + ( + EntityReference(project="proj", name="fleet"), + EntityReference(project="proj", name="fleet"), + ), + ], + ) + def test_valid(self, value, expected): + assert EntityReference.parse(value) == expected + + @pytest.mark.parametrize( + "value", + ["", "/name", "name/", "/", "a/b/c"], + ) + def test_invalid(self, value: str): + with pytest.raises(ValueError, match="Invalid entity reference"): + EntityReference.parse(value)