Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
"rich",
"rich-argparse",
"tqdm",
"simple-term-menu",
"questionary>=2.0.1",
"pydantic>=1.10.10,<2.0.0",
"pydantic-duality>=1.2.4",
"websocket-client",
Expand Down
118 changes: 106 additions & 12 deletions src/dstack/_internal/cli/commands/project.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,77 @@
import argparse
from typing import Any, Union
import sys
from typing import Any, Optional, Union

from requests import HTTPError
from rich.table import Table

try:
import questionary

is_project_menu_supported = sys.stdin.isatty()
except (ImportError, NotImplementedError, AttributeError):
is_project_menu_supported = False

import dstack.api.server
from dstack._internal.cli.commands import BaseCommand
from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console
from dstack._internal.core.errors import ClientError, CLIError
from dstack._internal.core.models.config import ProjectConfig
from dstack._internal.core.services.configs import ConfigManager
from dstack._internal.utils.logging import get_logger

logger = get_logger(__name__)


def select_default_project(
project_configs: list[ProjectConfig], default_project: Optional[ProjectConfig]
) -> Optional[ProjectConfig]:
"""Show an interactive menu to select a default project.

This method only prompts for selection and does not update the configuration.
Use `ConfigManager.configure_project()` and `ConfigManager.save()` to persist
the selected project as default.

Args:
project_configs: Non-empty list of available project configurations.
default_project: Currently default project, if any.

Returns:
Selected project configuration, or None if cancelled.

Raises:
CLIError: If `is_project_menu_supported` is False or `project_configs` is empty.
"""
if not is_project_menu_supported:
raise CLIError("Interactive menu is not supported on this platform")

if len(project_configs) == 0:
raise CLIError("No projects configured")

menu_entries = []
default_index = None
for i, project_config in enumerate(project_configs):
is_default = project_config.name == default_project.name if default_project else False
entry = f"{project_config.name} ({project_config.url})"
if is_default:
default_index = i
menu_entries.append((entry, i))

choices = [questionary.Choice(title=entry, value=index) for entry, index in menu_entries] # pyright: ignore[reportPossiblyUnboundVariable]
default_value = default_index
selected_index = questionary.select( # pyright: ignore[reportPossiblyUnboundVariable]
message="Select the default project:",
choices=choices,
default=default_value, # pyright: ignore[reportArgumentType]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid default value passed to questionary select

Medium Severity

When no project is marked as default, default_index remains None and is passed directly to questionary.select() as the default parameter. This causes a type mismatch (hence the pyright ignore comment) and may result in unexpected behavior. This scenario can occur when the default project is deleted, leaving other projects without a default flag.

Fix in Cursor Fix in Web

qmark="",
instruction="(↑↓ Enter)",
).ask()

if selected_index is not None and isinstance(selected_index, int):
return project_configs[selected_index]
return None


class ProjectCommand(BaseCommand):
NAME = "project"
DESCRIPTION = "Manage projects configs"
Expand Down Expand Up @@ -67,14 +125,17 @@ def _register(self):
# Set default subcommand
set_default_parser = subparsers.add_parser("set-default", help="Set default project")
set_default_parser.add_argument(
"name", type=str, help="The name of the project to set as default"
"name",
type=str,
nargs="?" if is_project_menu_supported else None,
help="The name of the project to set as default",
)
set_default_parser.set_defaults(subfunc=self._set_default)

def _command(self, args: argparse.Namespace):
super()._command(args)
if not hasattr(args, "subfunc"):
args.subfunc = self._list
args.subfunc = self._project
args.subfunc(args)

def _add(self, args: argparse.Namespace):
Expand Down Expand Up @@ -156,14 +217,47 @@ def _list(self, args: argparse.Namespace):

console.print(table)

def _project(self, args: argparse.Namespace):
if is_project_menu_supported and not getattr(args, "verbose", False):
config_manager = ConfigManager()
project_configs = config_manager.list_project_configs()
default_project = config_manager.get_project_config()
selected_project = select_default_project(project_configs, default_project)
if selected_project is not None:
config_manager.configure_project(
name=selected_project.name,
url=selected_project.url,
token=selected_project.token,
default=True,
)
config_manager.save()
console.print("[grey58]OK[/]")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated code for interactive project selection

Low Severity

The interactive project selection logic in _project (lines 222-234) is duplicated verbatim in _set_default's else branch (lines 251-263). Both blocks create a ConfigManager, get project configs, call select_default_project, and save the selection. This 13-line block could be extracted into a private helper method like _select_and_set_default_project() to reduce maintenance burden and ensure consistent behavior.

Additional Locations (1)

Fix in Cursor Fix in Web

else:
self._list(args)

def _set_default(self, args: argparse.Namespace):
config_manager = ConfigManager()
project_config = config_manager.get_project_config(args.name)
if project_config is None:
raise CLIError(f"Project '{args.name}' not found")
if args.name:
config_manager = ConfigManager()
project_config = config_manager.get_project_config(args.name)
if project_config is None:
raise CLIError(f"Project '{args.name}' not found")

config_manager.configure_project(
name=args.name, url=project_config.url, token=project_config.token, default=True
)
config_manager.save()
console.print("[grey58]OK[/]")
config_manager.configure_project(
name=args.name, url=project_config.url, token=project_config.token, default=True
)
config_manager.save()
console.print("[grey58]OK[/]")
else:
config_manager = ConfigManager()
project_configs = config_manager.list_project_configs()
default_project = config_manager.get_project_config()
selected_project = select_default_project(project_configs, default_project)
if selected_project is not None:
config_manager.configure_project(
name=selected_project.name,
url=selected_project.url,
token=selected_project.token,
default=True,
)
config_manager.save()
console.print("[grey58]OK[/]")