Skip to content

Commit c2bdff3

Browse files
[Feature]: Add JSON output option for CLI commands (#3335)
* [Feature]: Add JSON output option for CLI commands #3322 (WIP) This is a not-backward compatible change. It refactors existing `--json` code to make it eaasy to maintain and keep it extensible and backward-compatible in the future. * [Feature]: Add JSON output option for CLI commands #3322 (WIP) Review feedback * [Feature]: Add JSON output option for CLI commands #3322 (WIP) `dstack gateway` support * [Feature]: Add JSON output option for CLI commands #3322 (WIP) `dstack ps` support
1 parent 7c5370b commit c2bdff3

14 files changed

Lines changed: 242 additions & 126 deletions

File tree

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
confirm_ask,
1212
console,
1313
)
14-
from dstack._internal.cli.utils.gateway import get_gateways_table, print_gateways_table
14+
from dstack._internal.cli.utils.gateway import (
15+
get_gateways_table,
16+
print_gateways_json,
17+
print_gateways_table,
18+
)
19+
from dstack._internal.core.errors import CLIError
1520
from dstack._internal.core.models.backends.base import BackendType
1621
from dstack._internal.core.models.gateways import GatewayConfiguration
1722
from dstack._internal.utils.logging import get_logger
@@ -43,6 +48,19 @@ def _register(self):
4348
parser.add_argument(
4449
"-v", "--verbose", action="store_true", help="Show more information"
4550
)
51+
parser.add_argument(
52+
"--format",
53+
choices=["plain", "json"],
54+
default="plain",
55+
help="Output format (default: plain)",
56+
)
57+
parser.add_argument(
58+
"--json",
59+
action="store_const",
60+
const="json",
61+
dest="format",
62+
help="Output in JSON format (equivalent to --format json)",
63+
)
4664

4765
create_parser = subparsers.add_parser(
4866
"create",
@@ -91,9 +109,15 @@ def _command(self, args: argparse.Namespace):
91109
args.subfunc(args)
92110

93111
def _list(self, args: argparse.Namespace):
112+
if args.watch and args.format == "json":
113+
raise CLIError("JSON output is not supported together with --watch")
114+
94115
gateways = self.api.client.gateways.list(self.api.project)
95116
if not args.watch:
96-
print_gateways_table(gateways, verbose=args.verbose)
117+
if args.format == "json":
118+
print_gateways_json(gateways, project=self.api.project)
119+
else:
120+
print_gateways_table(gateways, verbose=args.verbose)
97121
return
98122

99123
try:

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import argparse
22
from pathlib import Path
3-
from typing import List
3+
from typing import List, Literal, cast
44

55
from dstack._internal.cli.commands import APIBaseCommand
66
from dstack._internal.cli.services.args import cpu_spec, disk_spec, gpu_spec
@@ -13,8 +13,8 @@
1313
from dstack._internal.cli.utils.run import print_offers_json, print_run_plan
1414
from dstack._internal.core.errors import CLIError
1515
from dstack._internal.core.models.configurations import ApplyConfigurationType, TaskConfiguration
16+
from dstack._internal.core.models.gpus import GpuGroup
1617
from dstack._internal.core.models.runs import RunSpec
17-
from dstack._internal.server.schemas.gpus import GpuGroup
1818
from dstack.api.utils import load_profile
1919

2020

@@ -130,7 +130,12 @@ def _command(self, args: argparse.Namespace):
130130
else:
131131
if args.group_by:
132132
gpus = self._list_gpus(args, run_spec)
133-
print_gpu_json(gpus, run_spec, args.group_by, self.api.project)
133+
print_gpu_json(
134+
gpus,
135+
run_spec,
136+
cast(List[Literal["gpu", "backend", "region", "count"]], args.group_by),
137+
self.api.project,
138+
)
134139
else:
135140
run_plan = self.api.client.runs.get_plan(
136141
self.api.project,

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
LIVE_TABLE_REFRESH_RATE_PER_SEC,
1111
console,
1212
)
13+
from dstack._internal.core.errors import CLIError
1314

1415

1516
class PsCommand(APIBaseCommand):
@@ -43,12 +44,31 @@ def _register(self):
4344
type=int,
4445
default=None,
4546
)
47+
self._parser.add_argument(
48+
"--format",
49+
choices=["plain", "json"],
50+
default="plain",
51+
help="Output format (default: plain)",
52+
)
53+
self._parser.add_argument(
54+
"--json",
55+
action="store_const",
56+
const="json",
57+
dest="format",
58+
help="Output in JSON format (equivalent to --format json)",
59+
)
4660

4761
def _command(self, args: argparse.Namespace):
4862
super()._command(args)
63+
if args.watch and args.format == "json":
64+
raise CLIError("JSON output is not supported together with --watch")
65+
4966
runs = self.api.runs.list(all=args.all, limit=args.last)
5067
if not args.watch:
51-
console.print(run_utils.get_runs_table(runs, verbose=args.verbose))
68+
if args.format == "json":
69+
run_utils.print_runs_json(self.api.project, runs)
70+
else:
71+
console.print(run_utils.get_runs_table(runs, verbose=args.verbose))
5272
return
5373

5474
try:

src/dstack/_internal/cli/models/__init__.py

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import List
2+
3+
from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model
4+
from dstack._internal.core.models.gateways import Gateway
5+
from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent
6+
7+
8+
class GatewayCommandOutputConfig(CoreConfig):
9+
json_dumps = pydantic_orjson_dumps_with_indent
10+
11+
12+
class GatewayCommandOutput(generate_dual_core_model(GatewayCommandOutputConfig)):
13+
"""JSON output model for `dstack gateway` command."""
14+
15+
project: str
16+
gateways: List[Gateway]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import List, Literal, Optional
2+
3+
from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model
4+
from dstack._internal.core.models.gpus import GpuGroup
5+
from dstack._internal.core.models.instances import InstanceOfferWithAvailability
6+
from dstack._internal.core.models.resources import ResourcesSpec
7+
from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent
8+
9+
10+
class OfferRequirementsConfig(CoreConfig):
11+
json_dumps = pydantic_orjson_dumps_with_indent
12+
13+
14+
class OfferRequirements(generate_dual_core_model(OfferRequirementsConfig)):
15+
"""Profile/requirements output model for CLI commands."""
16+
17+
resources: ResourcesSpec
18+
max_price: Optional[float] = None
19+
spot: Optional[bool] = None
20+
reservation: Optional[str] = None
21+
22+
23+
class OfferCommandOutputConfig(CoreConfig):
24+
json_dumps = pydantic_orjson_dumps_with_indent
25+
26+
27+
class OfferCommandOutput(generate_dual_core_model(OfferCommandOutputConfig)):
28+
"""JSON output model for `dstack offer` command."""
29+
30+
project: str
31+
user: str
32+
requirements: OfferRequirements
33+
offers: List[InstanceOfferWithAvailability]
34+
total_offers: int
35+
36+
37+
class OfferCommandGroupByGpuOutputConfig(CoreConfig):
38+
json_dumps = pydantic_orjson_dumps_with_indent
39+
40+
41+
class OfferCommandGroupByGpuOutput(generate_dual_core_model(OfferCommandGroupByGpuOutputConfig)):
42+
"""JSON output model for `dstack offer` command with GPU grouping."""
43+
44+
project: str
45+
requirements: OfferRequirements
46+
group_by: List[Literal["gpu", "backend", "region", "count"]]
47+
gpus: List[GpuGroup]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import List
2+
3+
from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model
4+
from dstack._internal.core.models.runs import Run
5+
from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent
6+
7+
8+
class PsCommandOutputConfig(CoreConfig):
9+
json_dumps = pydantic_orjson_dumps_with_indent
10+
11+
12+
class PsCommandOutput(generate_dual_core_model(PsCommandOutputConfig)):
13+
"""JSON output model for `dstack ps` command."""
14+
15+
project: str
16+
runs: List[Run]

src/dstack/_internal/cli/utils/gateway.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from rich.table import Table
44

5+
from dstack._internal.cli.models.gateways import GatewayCommandOutput
56
from dstack._internal.cli.utils.common import add_row_from_dict, console
67
from dstack._internal.core.models.gateways import Gateway
78
from dstack._internal.utils.common import DateFormatter, pretty_date
@@ -13,6 +14,15 @@ def print_gateways_table(gateways: List[Gateway], verbose: bool = False):
1314
console.print()
1415

1516

17+
def print_gateways_json(gateways: List[Gateway], project: str) -> None:
18+
"""Print gateways information in JSON format."""
19+
output = GatewayCommandOutput(
20+
project=project,
21+
gateways=gateways,
22+
)
23+
print(output.json())
24+
25+
1626
def get_gateways_table(
1727
gateways: List[Gateway],
1828
verbose: bool = False,

src/dstack/_internal/cli/utils/gpu.py

Lines changed: 17 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,37 @@
11
import shutil
2-
from typing import List
2+
from typing import List, Literal
33

44
from rich.table import Table
55

6+
from dstack._internal.cli.models.offers import OfferCommandGroupByGpuOutput, OfferRequirements
67
from dstack._internal.cli.utils.common import console
8+
from dstack._internal.core.models.gpus import GpuGroup
79
from dstack._internal.core.models.profiles import SpotPolicy
810
from dstack._internal.core.models.runs import Requirements, RunSpec, get_policy_map
9-
from dstack._internal.server.schemas.gpus import GpuGroup
1011

1112

12-
def print_gpu_json(gpus, run_spec, group_by_cli, api_project):
13+
def print_gpu_json(
14+
gpus: List[GpuGroup],
15+
run_spec: RunSpec,
16+
group_by: List[Literal["gpu", "backend", "region", "count"]],
17+
project: str,
18+
):
1319
"""Print GPU information in JSON format."""
14-
req = Requirements(
20+
req = OfferRequirements(
1521
resources=run_spec.configuration.resources,
1622
max_price=run_spec.merged_profile.max_price,
1723
spot=get_policy_map(run_spec.merged_profile.spot_policy, default=SpotPolicy.AUTO),
1824
reservation=run_spec.configuration.reservation,
1925
)
2026

21-
if req.spot is None:
22-
spot_policy = "auto"
23-
elif req.spot:
24-
spot_policy = "spot"
25-
else:
26-
spot_policy = "on-demand"
27-
28-
output = {
29-
"project": api_project,
30-
"user": "admin", # TODO: Get actual user name
31-
"resources": req.resources.dict(),
32-
"spot_policy": spot_policy,
33-
"max_price": req.max_price,
34-
"reservation": run_spec.configuration.reservation,
35-
"group_by": group_by_cli,
36-
"gpus": [],
37-
}
38-
39-
for gpu_group in gpus:
40-
gpu_data = {
41-
"name": gpu_group.name,
42-
"memory_mib": gpu_group.memory_mib,
43-
"vendor": gpu_group.vendor.value,
44-
"availability": [av.value for av in gpu_group.availability],
45-
"spot": gpu_group.spot,
46-
"count": {"min": gpu_group.count.min, "max": gpu_group.count.max},
47-
"price": {"min": gpu_group.price.min, "max": gpu_group.price.max},
48-
}
49-
50-
if gpu_group.backend:
51-
gpu_data["backend"] = gpu_group.backend.value
52-
if gpu_group.backends:
53-
gpu_data["backends"] = [b.value for b in gpu_group.backends]
54-
if gpu_group.region:
55-
gpu_data["region"] = gpu_group.region
56-
if gpu_group.regions:
57-
gpu_data["regions"] = gpu_group.regions
58-
59-
output["gpus"].append(gpu_data)
60-
61-
import json
27+
output = OfferCommandGroupByGpuOutput(
28+
project=project,
29+
requirements=req,
30+
group_by=group_by,
31+
gpus=gpus,
32+
)
6233

63-
print(json.dumps(output, indent=2))
34+
print(output.json())
6435

6536

6637
def print_gpu_table(gpus: List[GpuGroup], run_spec: RunSpec, group_by: List[str], project: str):

src/dstack/_internal/cli/utils/run.py

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from rich.markup import escape
55
from rich.table import Table
66

7+
from dstack._internal.cli.models.offers import OfferCommandOutput, OfferRequirements
8+
from dstack._internal.cli.models.runs import PsCommandOutput
79
from dstack._internal.cli.utils.common import NO_OFFERS_WARNING, add_row_from_dict, console
810
from dstack._internal.core.models.backends.base import BackendType
911
from dstack._internal.core.models.configurations import DevEnvironmentConfiguration
@@ -14,6 +16,7 @@
1416
)
1517
from dstack._internal.core.models.profiles import (
1618
DEFAULT_RUN_TERMINATION_IDLE_TIME,
19+
SpotPolicy,
1720
TerminationPolicy,
1821
)
1922
from dstack._internal.core.models.runs import (
@@ -24,6 +27,7 @@
2427
ProbeSpec,
2528
RunPlan,
2629
RunStatus,
30+
get_policy_map,
2731
)
2832
from dstack._internal.core.models.runs import (
2933
Run as CoreRun,
@@ -43,33 +47,31 @@ def print_offers_json(run_plan: RunPlan, run_spec):
4347
"""Print offers information in JSON format."""
4448
job_plan = run_plan.job_plans[0]
4549

46-
output = {
47-
"project": run_plan.project_name,
48-
"user": run_plan.user,
49-
"resources": job_plan.job_spec.requirements.resources.dict(),
50-
"max_price": (job_plan.job_spec.requirements.max_price),
51-
"spot": run_spec.configuration.spot_policy,
52-
"reservation": run_plan.run_spec.configuration.reservation,
53-
"offers": [],
54-
"total_offers": job_plan.total_offers,
55-
}
50+
requirements = OfferRequirements(
51+
resources=job_plan.job_spec.requirements.resources,
52+
max_price=job_plan.job_spec.requirements.max_price,
53+
spot=get_policy_map(run_spec.configuration.spot_policy, default=SpotPolicy.AUTO),
54+
reservation=run_plan.run_spec.configuration.reservation,
55+
)
5656

57-
for offer in job_plan.offers:
58-
output["offers"].append(
59-
{
60-
"backend": ("ssh" if offer.backend.value == "remote" else offer.backend.value),
61-
"region": offer.region,
62-
"instance_type": offer.instance.name,
63-
"resources": offer.instance.resources.dict(),
64-
"spot": offer.instance.resources.spot,
65-
"price": float(offer.price),
66-
"availability": offer.availability.value,
67-
}
68-
)
57+
output = OfferCommandOutput(
58+
project=run_plan.project_name,
59+
user=run_plan.user,
60+
requirements=requirements,
61+
offers=job_plan.offers,
62+
total_offers=job_plan.total_offers,
63+
)
64+
65+
print(output.json())
6966

70-
import json
7167

72-
print(json.dumps(output, indent=2))
68+
def print_runs_json(project: str, runs: List[Run]) -> None:
69+
"""Print runs information in JSON format."""
70+
output = PsCommandOutput(
71+
project=project,
72+
runs=[r._run for r in runs],
73+
)
74+
print(output.json())
7375

7476

7577
def print_run_plan(

0 commit comments

Comments
 (0)