|
1 | 1 | import argparse |
2 | | -from typing import Any, Union |
| 2 | +import sys |
| 3 | +from typing import Any, Optional, Union |
3 | 4 |
|
4 | 5 | from requests import HTTPError |
5 | 6 | from rich.table import Table |
6 | 7 |
|
| 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 | + |
7 | 15 | import dstack.api.server |
8 | 16 | from dstack._internal.cli.commands import BaseCommand |
9 | 17 | from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console |
10 | 18 | from dstack._internal.core.errors import ClientError, CLIError |
| 19 | +from dstack._internal.core.models.config import ProjectConfig |
11 | 20 | from dstack._internal.core.services.configs import ConfigManager |
12 | 21 | from dstack._internal.utils.logging import get_logger |
13 | 22 |
|
14 | 23 | logger = get_logger(__name__) |
15 | 24 |
|
16 | 25 |
|
| 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 | + |
17 | 75 | class ProjectCommand(BaseCommand): |
18 | 76 | NAME = "project" |
19 | 77 | DESCRIPTION = "Manage projects configs" |
@@ -67,14 +125,17 @@ def _register(self): |
67 | 125 | # Set default subcommand |
68 | 126 | set_default_parser = subparsers.add_parser("set-default", help="Set default project") |
69 | 127 | 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", |
71 | 132 | ) |
72 | 133 | set_default_parser.set_defaults(subfunc=self._set_default) |
73 | 134 |
|
74 | 135 | def _command(self, args: argparse.Namespace): |
75 | 136 | super()._command(args) |
76 | 137 | if not hasattr(args, "subfunc"): |
77 | | - args.subfunc = self._list |
| 138 | + args.subfunc = self._project |
78 | 139 | args.subfunc(args) |
79 | 140 |
|
80 | 141 | def _add(self, args: argparse.Namespace): |
@@ -156,14 +217,47 @@ def _list(self, args: argparse.Namespace): |
156 | 217 |
|
157 | 218 | console.print(table) |
158 | 219 |
|
| 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 | + |
159 | 238 | 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") |
164 | 244 |
|
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