Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic CLI completion #2285

Merged
merged 20 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
31 changes: 31 additions & 0 deletions docs/docs/installation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,37 @@ Configuration is updated at ~/.dstack/config.yml

This configuration is stored in `~/.dstack/config.yml`.

### (Optional) CLI Autocompletion

`dstack` supports shell autocompletion for `bash` and `zsh`.

=== "bash"

<div class="termy">

```shell
$ eval "$(dstack completion bash)"
$ echo "$(dstack completion bash)" >> ~/.bashrc
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure it's a good idea to write directly to ~/.bashrc or ~/.zshrc. Let me fix it.

```

</div>

=== "zsh"

> If you get an error similiar to `2: command not found: compdef`, then add the following line to the beginning of your ~/.zshrc file:
>
> `autoload -Uz compinit && compinit`

```shell
$ eval "$(dstack completion zsh)"
$ echo "$(dstack completion zsh)" >> ~/.zshrc
```

</div>




!!! info "What's next?"
1. Check the [server/config.yml reference](../reference/server/config.yml.md) on how to configure backends
2. Check [SSH fleets](../concepts/fleets.md#ssh) to learn about running on your on-prem servers
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def get_long_description():
"filelock",
"psutil",
"gpuhunt>=0.0.19,<0.1.0",
"argcomplete>=3.5.0",
]

GATEWAY_AND_SERVER_COMMON_DEPS = [
Expand Down
3 changes: 2 additions & 1 deletion src/dstack/_internal/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from rich_argparse import RichHelpFormatter

from dstack._internal.cli.services.completion import ProjectNameCompleter
from dstack._internal.cli.utils.common import configure_logging
from dstack.api import Client

Expand Down Expand Up @@ -61,7 +62,7 @@ def _register(self):
help="The name of the project. Defaults to [code]$DSTACK_PROJECT[/]",
metavar="NAME",
default=os.getenv("DSTACK_PROJECT"),
)
).completer = ProjectNameCompleter()

def _command(self, args: argparse.Namespace):
configure_logging()
Expand Down
6 changes: 4 additions & 2 deletions src/dstack/_internal/cli/commands/apply.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import argparse
from pathlib import Path

from argcomplete import FilesCompleter

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.configurators import (
get_apply_configurator_class,
Expand Down Expand Up @@ -42,7 +44,7 @@ def _register(self):
metavar="FILE",
help="The path to the configuration file. Defaults to [code]$PWD/.dstack.yml[/]",
dest="configuration_file",
)
).completer = FilesCompleter(allowednames=["*.yml", "*.yaml"])
self._parser.add_argument(
"-y",
"--yes",
Expand All @@ -57,7 +59,7 @@ def _register(self):
self._parser.add_argument(
"-d",
"--detach",
help="Exit immediately after sumbitting configuration",
help="Exit immediately after submitting configuration",
action="store_true",
)
repo_group = self._parser.add_argument_group("Repo Options")
Expand Down
3 changes: 2 additions & 1 deletion src/dstack/_internal/cli/commands/attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.args import port_mapping
from dstack._internal.cli.services.completion import RunNameCompleter
from dstack._internal.cli.utils.common import console
from dstack._internal.core.consts import DSTACK_RUNNER_HTTP_PORT
from dstack._internal.core.errors import CLIError
Expand Down Expand Up @@ -57,7 +58,7 @@ def _register(self):
type=int,
default=0,
)
self._parser.add_argument("run_name")
self._parser.add_argument("run_name").completer = RunNameCompleter()

def _command(self, args: argparse.Namespace):
super()._command(args)
Expand Down
20 changes: 20 additions & 0 deletions src/dstack/_internal/cli/commands/completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import argcomplete

from dstack._internal.cli.commands import BaseCommand


class CompletionCommand(BaseCommand):
NAME = "completion"
DESCRIPTION = "Generate shell completion scripts"

def _register(self):
super()._register()
self._parser.add_argument(
"shell",
help="The shell to generate the completion script for",
choices=["bash", "zsh"],
)

def _command(self, args):
super()._command(args)
print(argcomplete.shellcode(["dstack"], shell=args.shell))
4 changes: 3 additions & 1 deletion src/dstack/_internal/cli/commands/delete.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import argparse
from pathlib import Path

from argcomplete import FilesCompleter

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.configurators import (
get_apply_configurator_class,
Expand All @@ -22,7 +24,7 @@ def _register(self):
metavar="FILE",
help="The path to the configuration file. Defaults to [code]$PWD/.dstack.yml[/]",
dest="configuration_file",
)
).completer = FilesCompleter(allowednames=["*.yml", "*.yaml"])
self._parser.add_argument(
"-y",
"--yes",
Expand Down
3 changes: 2 additions & 1 deletion src/dstack/_internal/cli/commands/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rich.live import Live

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import FleetNameCompleter
from dstack._internal.cli.utils.common import (
LIVE_TABLE_PROVISION_INTERVAL_SECS,
LIVE_TABLE_REFRESH_RATE_PER_SEC,
Expand Down Expand Up @@ -47,7 +48,7 @@ def _register(self):
delete_parser.add_argument(
"name",
help="The name of the fleet",
)
).completer = FleetNameCompleter()
delete_parser.add_argument(
"-i",
"--instance",
Expand Down
9 changes: 7 additions & 2 deletions src/dstack/_internal/cli/commands/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rich.live import Live

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import GatewayNameCompleter
from dstack._internal.cli.utils.common import (
LIVE_TABLE_PROVISION_INTERVAL_SECS,
LIVE_TABLE_REFRESH_RATE_PER_SEC,
Expand Down Expand Up @@ -59,7 +60,9 @@ def _register(self):
"delete", help="Delete a gateway", formatter_class=self._parser.formatter_class
)
delete_parser.set_defaults(subfunc=self._delete)
delete_parser.add_argument("name", help="The name of the gateway")
delete_parser.add_argument(
"name", help="The name of the gateway"
).completer = GatewayNameCompleter()
delete_parser.add_argument(
"-y", "--yes", action="store_true", help="Don't ask for confirmation"
)
Expand All @@ -68,7 +71,9 @@ def _register(self):
"update", help="Update a gateway", formatter_class=self._parser.formatter_class
)
update_parser.set_defaults(subfunc=self._update)
update_parser.add_argument("name", help="The name of the gateway")
update_parser.add_argument(
"name", help="The name of the gateway"
).completer = GatewayNameCompleter()
update_parser.add_argument(
"--set-default", action="store_true", help="Set it the default gateway for the project"
)
Expand Down
5 changes: 3 additions & 2 deletions src/dstack/_internal/cli/commands/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import RunNameCompleter
from dstack._internal.core.errors import CLIError
from dstack._internal.utils.logging import get_logger

Expand Down Expand Up @@ -33,7 +34,7 @@ def _register(self):
)
self._parser.add_argument(
"--replica",
help="The relica number. Defaults to 0.",
help="The replica number. Defaults to 0.",
type=int,
default=0,
)
Expand All @@ -43,7 +44,7 @@ def _register(self):
type=int,
default=0,
)
self._parser.add_argument("run_name")
self._parser.add_argument("run_name").completer = RunNameCompleter(all=True)

def _command(self, args: argparse.Namespace):
super()._command(args)
Expand Down
3 changes: 2 additions & 1 deletion src/dstack/_internal/cli/commands/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rich.table import Table

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import RunNameCompleter
from dstack._internal.cli.utils.common import (
LIVE_TABLE_PROVISION_INTERVAL_SECS,
LIVE_TABLE_REFRESH_RATE_PER_SEC,
Expand All @@ -25,7 +26,7 @@ class StatsCommand(APIBaseCommand):

def _register(self):
super()._register()
self._parser.add_argument("run_name")
self._parser.add_argument("run_name").completer = RunNameCompleter()
self._parser.add_argument(
"-w",
"--watch",
Expand Down
3 changes: 2 additions & 1 deletion src/dstack/_internal/cli/commands/stop.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import RunNameCompleter
from dstack._internal.cli.utils.common import confirm_ask
from dstack._internal.core.errors import CLIError

Expand All @@ -13,7 +14,7 @@ def _register(self):
super()._register()
self._parser.add_argument("-x", "--abort", action="store_true")
self._parser.add_argument("-y", "--yes", action="store_true")
self._parser.add_argument("run_name")
self._parser.add_argument("run_name").completer = RunNameCompleter()

def _command(self, args: argparse.Namespace):
super()._command(args)
Expand Down
3 changes: 2 additions & 1 deletion src/dstack/_internal/cli/commands/volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rich.live import Live

from dstack._internal.cli.commands import APIBaseCommand
from dstack._internal.cli.services.completion import VolumeNameCompleter
from dstack._internal.cli.utils.common import (
LIVE_TABLE_PROVISION_INTERVAL_SECS,
LIVE_TABLE_REFRESH_RATE_PER_SEC,
Expand Down Expand Up @@ -47,7 +48,7 @@ def _register(self):
delete_parser.add_argument(
"name",
help="The name of the volume",
)
).completer = VolumeNameCompleter()
delete_parser.add_argument(
"-y", "--yes", help="Don't ask for confirmation", action="store_true"
)
Expand Down
6 changes: 6 additions & 0 deletions src/dstack/_internal/cli/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import argparse

import argcomplete
from rich.markup import escape
from rich_argparse import RichHelpFormatter

from dstack._internal.cli.commands.apply import ApplyCommand
from dstack._internal.cli.commands.attach import AttachCommand
from dstack._internal.cli.commands.completion import CompletionCommand
from dstack._internal.cli.commands.config import ConfigCommand
from dstack._internal.cli.commands.delete import DeleteCommand
from dstack._internal.cli.commands.fleet import FleetCommand
Expand Down Expand Up @@ -72,9 +74,13 @@ def main():
StatsCommand.register(subparsers)
StopCommand.register(subparsers)
VolumeCommand.register(subparsers)
CompletionCommand.register(subparsers)

argcomplete.autocomplete(parser, always_complete_options=False)

args, unknown_args = parser.parse_known_args()
args.unknown = unknown_args

try:
check_for_updates()
get_ssh_client_info()
Expand Down
86 changes: 86 additions & 0 deletions src/dstack/_internal/cli/services/completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import argparse
import os
from abc import ABC, abstractmethod
from typing import Iterable, List, Optional

import argcomplete
from argcomplete.completers import BaseCompleter

from dstack._internal.core.errors import ConfigurationError
from dstack._internal.core.services.configs import ConfigManager
from dstack.api import Client


class BaseAPINameCompleter(BaseCompleter, ABC):
"""
Base class for name completers that fetch resource names via the API.
"""

def __init__(self):
super().__init__()

def get_api(self, parsed_args: argparse.Namespace) -> Optional[Client]:
argcomplete.debug(f"{self.__class__.__name__}: Retrieving API client")
project = getattr(parsed_args, "project", os.getenv("DSTACK_PROJECT"))
try:
return Client.from_config(project_name=project)
except ConfigurationError as e:
argcomplete.debug(f"{self.__class__.__name__}: Error initializing API client: {e}")
return None

def __call__(self, prefix: str, parsed_args: argparse.Namespace, **kwargs) -> List[str]:
api = self.get_api(parsed_args)
if api is None:
return []

argcomplete.debug(f"{self.__class__.__name__}: Fetching completions")
try:
resource_names = self.fetch_resource_names(api)
return [name for name in resource_names if name.startswith(prefix)]
except Exception as e:
argcomplete.debug(
f"{self.__class__.__name__}: Error fetching resource completions: {e}"
)
return []

@abstractmethod
def fetch_resource_names(self, api: Client) -> Iterable[str]:
"""
Returns an iterable of resource names.
"""
pass


class RunNameCompleter(BaseAPINameCompleter):
def __init__(self, all: bool = False):
super().__init__()
self.all = all

def fetch_resource_names(self, api: Client) -> Iterable[str]:
return [r.name for r in api.runs.list(self.all)]


class FleetNameCompleter(BaseAPINameCompleter):
def fetch_resource_names(self, api: Client) -> Iterable[str]:
return [r.name for r in api.client.fleets.list(api.project)]


class VolumeNameCompleter(BaseAPINameCompleter):
def fetch_resource_names(self, api: Client) -> Iterable[str]:
return [r.name for r in api.client.volumes.list(api.project)]


class GatewayNameCompleter(BaseAPINameCompleter):
def fetch_resource_names(self, api: Client) -> Iterable[str]:
return [r.name for r in api.client.gateways.list(api.project)]


class ProjectNameCompleter(BaseCompleter):
"""
Completer for local project names.
"""

def __call__(self, prefix: str, parsed_args: argparse.Namespace, **kwargs) -> List[str]:
argcomplete.debug(f"{self.__class__.__name__}: Listing projects from ConfigManager")
projects = ConfigManager().list_projects()
return [p for p in projects if p.startswith(prefix)]
3 changes: 3 additions & 0 deletions src/dstack/_internal/core/services/configs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ def configure_project(self, name: str, url: str, token: str, default: bool):
if len(self.config.projects) == 1:
self.config.projects[0].default = True

def list_projects(self):
return [project.name for project in self.config.projects]

def delete_project(self, name: str):
self.config.projects = [p for p in self.config.projects if p.name != name]

Expand Down