Skip to content

Commit 802c450

Browse files
[UX] Make dstack project and dstack project set-default interactive for default project selection (#3488)
1 parent 330eb1f commit 802c450

2 files changed

Lines changed: 107 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ dependencies = [
2525
"rich",
2626
"rich-argparse",
2727
"tqdm",
28-
"simple-term-menu",
28+
"questionary>=2.0.1",
2929
"pydantic>=1.10.10,<2.0.0",
3030
"pydantic-duality>=1.2.4",
3131
"websocket-client",

src/dstack/_internal/cli/commands/project.py

Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,77 @@
11
import argparse
2-
from typing import Any, Union
2+
import sys
3+
from typing import Any, Optional, Union
34

45
from requests import HTTPError
56
from rich.table import Table
67

8+
try:
9+
import questionary
10+
11+
is_project_menu_supported = sys.stdin.isatty()
12+
except (ImportError, NotImplementedError, AttributeError):
13+
is_project_menu_supported = False
14+
715
import dstack.api.server
816
from dstack._internal.cli.commands import BaseCommand
917
from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console
1018
from dstack._internal.core.errors import ClientError, CLIError
19+
from dstack._internal.core.models.config import ProjectConfig
1120
from dstack._internal.core.services.configs import ConfigManager
1221
from dstack._internal.utils.logging import get_logger
1322

1423
logger = get_logger(__name__)
1524

1625

26+
def select_default_project(
27+
project_configs: list[ProjectConfig], default_project: Optional[ProjectConfig]
28+
) -> Optional[ProjectConfig]:
29+
"""Show an interactive menu to select a default project.
30+
31+
This method only prompts for selection and does not update the configuration.
32+
Use `ConfigManager.configure_project()` and `ConfigManager.save()` to persist
33+
the selected project as default.
34+
35+
Args:
36+
project_configs: Non-empty list of available project configurations.
37+
default_project: Currently default project, if any.
38+
39+
Returns:
40+
Selected project configuration, or None if cancelled.
41+
42+
Raises:
43+
CLIError: If `is_project_menu_supported` is False or `project_configs` is empty.
44+
"""
45+
if not is_project_menu_supported:
46+
raise CLIError("Interactive menu is not supported on this platform")
47+
48+
if len(project_configs) == 0:
49+
raise CLIError("No projects configured")
50+
51+
menu_entries = []
52+
default_index = None
53+
for i, project_config in enumerate(project_configs):
54+
is_default = project_config.name == default_project.name if default_project else False
55+
entry = f"{project_config.name} ({project_config.url})"
56+
if is_default:
57+
default_index = i
58+
menu_entries.append((entry, i))
59+
60+
choices = [questionary.Choice(title=entry, value=index) for entry, index in menu_entries] # pyright: ignore[reportPossiblyUnboundVariable]
61+
default_value = default_index
62+
selected_index = questionary.select( # pyright: ignore[reportPossiblyUnboundVariable]
63+
message="Select the default project:",
64+
choices=choices,
65+
default=default_value, # pyright: ignore[reportArgumentType]
66+
qmark="",
67+
instruction="(↑↓ Enter)",
68+
).ask()
69+
70+
if selected_index is not None and isinstance(selected_index, int):
71+
return project_configs[selected_index]
72+
return None
73+
74+
1775
class ProjectCommand(BaseCommand):
1876
NAME = "project"
1977
DESCRIPTION = "Manage projects configs"
@@ -67,14 +125,17 @@ def _register(self):
67125
# Set default subcommand
68126
set_default_parser = subparsers.add_parser("set-default", help="Set default project")
69127
set_default_parser.add_argument(
70-
"name", type=str, help="The name of the project to set as default"
128+
"name",
129+
type=str,
130+
nargs="?" if is_project_menu_supported else None,
131+
help="The name of the project to set as default",
71132
)
72133
set_default_parser.set_defaults(subfunc=self._set_default)
73134

74135
def _command(self, args: argparse.Namespace):
75136
super()._command(args)
76137
if not hasattr(args, "subfunc"):
77-
args.subfunc = self._list
138+
args.subfunc = self._project
78139
args.subfunc(args)
79140

80141
def _add(self, args: argparse.Namespace):
@@ -156,14 +217,47 @@ def _list(self, args: argparse.Namespace):
156217

157218
console.print(table)
158219

220+
def _project(self, args: argparse.Namespace):
221+
if is_project_menu_supported and not getattr(args, "verbose", False):
222+
config_manager = ConfigManager()
223+
project_configs = config_manager.list_project_configs()
224+
default_project = config_manager.get_project_config()
225+
selected_project = select_default_project(project_configs, default_project)
226+
if selected_project is not None:
227+
config_manager.configure_project(
228+
name=selected_project.name,
229+
url=selected_project.url,
230+
token=selected_project.token,
231+
default=True,
232+
)
233+
config_manager.save()
234+
console.print("[grey58]OK[/]")
235+
else:
236+
self._list(args)
237+
159238
def _set_default(self, args: argparse.Namespace):
160-
config_manager = ConfigManager()
161-
project_config = config_manager.get_project_config(args.name)
162-
if project_config is None:
163-
raise CLIError(f"Project '{args.name}' not found")
239+
if args.name:
240+
config_manager = ConfigManager()
241+
project_config = config_manager.get_project_config(args.name)
242+
if project_config is None:
243+
raise CLIError(f"Project '{args.name}' not found")
164244

165-
config_manager.configure_project(
166-
name=args.name, url=project_config.url, token=project_config.token, default=True
167-
)
168-
config_manager.save()
169-
console.print("[grey58]OK[/]")
245+
config_manager.configure_project(
246+
name=args.name, url=project_config.url, token=project_config.token, default=True
247+
)
248+
config_manager.save()
249+
console.print("[grey58]OK[/]")
250+
else:
251+
config_manager = ConfigManager()
252+
project_configs = config_manager.list_project_configs()
253+
default_project = config_manager.get_project_config()
254+
selected_project = select_default_project(project_configs, default_project)
255+
if selected_project is not None:
256+
config_manager.configure_project(
257+
name=selected_project.name,
258+
url=selected_project.url,
259+
token=selected_project.token,
260+
default=True,
261+
)
262+
config_manager.save()
263+
console.print("[grey58]OK[/]")

0 commit comments

Comments
 (0)