Skip to content

Commit 039c182

Browse files
mwaskommodal-bot
authored andcommitted
Support global --profile option in modal CLI (#53843)
GitOrigin-RevId: d8556dd8e521bfc10844eee02fb95cbd40bc4f05
1 parent e1d5ff0 commit 039c182

12 files changed

Lines changed: 288 additions & 30 deletions

File tree

py/CHANGELOG_DEV.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44

55
- Added `Sandbox.logs`(/docs/sdk/py/latest/Sandbox#logs) namespace to retrieve Sandbox entrypoint logs directly from the SDK. The namespace has two different methods, allowing you `fetch()` logs from a specific date/time range, or `tail()` the most recent logs.
66
- Added support for setting the default member Role when creating Restricted Environments through the Python SDK and CLI.
7+
- The `modal` CLI now accepts a global `--profile` option for simpler ad hoc profile selection.

py/modal/cli/_help.py

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import shutil
99
import sys
10+
from collections.abc import Sequence
1011
from typing import Any
1112

1213
import click
@@ -28,6 +29,83 @@
2829
_HELP_PADDING = 1
2930

3031

32+
class ModalGlobalOption(click.Option):
33+
"""An option supported at every level of the Modal CLI."""
34+
35+
def __init__(self, *args: Any, environment_variable: str, **kwargs: Any) -> None:
36+
kwargs.setdefault("expose_value", False)
37+
super().__init__(*args, **kwargs)
38+
self.environment_variable = environment_variable
39+
40+
def set_environment_value(self, value: object) -> None:
41+
os.environ[self.environment_variable] = str(value)
42+
43+
44+
class ModalProfileOption(ModalGlobalOption):
45+
"""Global option that updates Modal's active-profile cache."""
46+
47+
def set_environment_value(self, value: object) -> None:
48+
from modal.config import _set_profile
49+
50+
# The profile is a global variable and currently set when modal is imported
51+
# We could probably clean this up and simplify to just setting MODAL_PROFILE
52+
_set_profile(str(value))
53+
54+
55+
def _root_global_options(ctx: click.Context) -> list[tuple[ModalGlobalOption, click.Context]]:
56+
root_ctx = ctx.find_root()
57+
return [
58+
(param, root_ctx) for param in root_ctx.command.get_params(root_ctx) if isinstance(param, ModalGlobalOption)
59+
]
60+
61+
62+
def _global_option_token_length(ctx: click.Context, args: list[str], index: int) -> int:
63+
option_name = args[index].split("=", 1)[0]
64+
for option, _ in _root_global_options(ctx):
65+
if option_name in (*option.opts, *option.secondary_opts):
66+
return 1 if "=" in args[index] or option.is_flag else option.nargs + 1
67+
return 0
68+
69+
70+
def _consume_global_options(ctx: click.Context, args: list[str]) -> list[str]:
71+
remaining_args: list[str] = []
72+
value: str | bool
73+
index = 0
74+
while index < len(args):
75+
arg = args[index]
76+
option_name, separator, option_value = arg.partition("=")
77+
global_option = next(
78+
(
79+
option
80+
for option, _ in _root_global_options(ctx)
81+
if option_name in (*option.opts, *option.secondary_opts)
82+
),
83+
None,
84+
)
85+
if global_option is None:
86+
remaining_args.append(arg)
87+
index += 1
88+
continue
89+
90+
if separator:
91+
value = option_value
92+
index += 1
93+
elif global_option.is_flag:
94+
value = global_option.flag_value
95+
if option_name in global_option.secondary_opts and isinstance(value, bool):
96+
value = not value
97+
index += 1
98+
else:
99+
if index + global_option.nargs >= len(args):
100+
raise click.UsageError(f"Option '{option_name}' requires an argument.", ctx)
101+
value = args[index + 1]
102+
index += global_option.nargs + 1
103+
104+
global_option.set_environment_value(value)
105+
106+
return remaining_args
107+
108+
31109
def use_rich_style() -> bool:
32110
"""Whether help output should be rendered in the rich style."""
33111
env = os.environ.get("MODAL_RICH_CLI") # TODO move to config
@@ -82,11 +160,11 @@ def _option_label(param: click.Parameter, ctx: click.Context) -> Text:
82160

83161
def _build_options(cmd: click.Command, ctx: click.Context) -> RenderableType | None:
84162
rows: list[tuple[Text, str]] = []
85-
for param in cmd.get_params(ctx):
86-
rec = param.get_help_record(ctx)
163+
for param, param_ctx in _options_with_global_options(cmd, ctx):
164+
rec = param.get_help_record(param_ctx)
87165
if rec is None: # skips arguments and hidden options
88166
continue
89-
rows.append((_option_label(param, ctx), rec[1] or ""))
167+
rows.append((_option_label(param, param_ctx), rec[1] or ""))
90168
if not rows:
91169
return None
92170

@@ -98,6 +176,36 @@ def _build_options(cmd: click.Command, ctx: click.Context) -> RenderableType | N
98176
return Group(Text("Options", style=_HEADING_STYLE), table)
99177

100178

179+
def _global_options(ctx: click.Context) -> Sequence[tuple[click.Option, click.Context]]:
180+
root_ctx = ctx.find_root()
181+
if root_ctx is ctx:
182+
return []
183+
return _root_global_options(ctx)
184+
185+
186+
def _options_with_global_options(cmd: click.Command, ctx: click.Context) -> list[tuple[click.Parameter, click.Context]]:
187+
params = [(param, ctx) for param in cmd.get_params(ctx)]
188+
if global_options := _global_options(ctx):
189+
for index, (param, _) in enumerate(params):
190+
if "--help" in param.opts or "--help" in param.secondary_opts:
191+
params[index:index] = global_options
192+
break
193+
else:
194+
params.extend(global_options)
195+
return params
196+
197+
198+
def _format_options(cmd: click.Command, ctx: click.Context, formatter: click.HelpFormatter) -> None:
199+
records = [
200+
rec
201+
for param, param_ctx in _options_with_global_options(cmd, ctx)
202+
if (rec := param.get_help_record(param_ctx)) is not None
203+
]
204+
if records:
205+
with formatter.section("Options"):
206+
formatter.write_dl(records)
207+
208+
101209
def _build_epilog(cmd: click.Command) -> RenderableType | None:
102210
if not cmd.epilog:
103211
return None
@@ -114,6 +222,10 @@ def group_commands_by_panel(group: click.Group) -> dict[str, list[tuple[str, cli
114222
return panels
115223

116224

225+
def _has_visible_commands(group: click.Group) -> bool:
226+
return bool(group_commands_by_panel(group))
227+
228+
117229
def _build_commands(group: click.Group, available_width: int) -> RenderableType | None:
118230
panels = group_commands_by_panel(group)
119231
if not panels:
@@ -189,6 +301,9 @@ def __init__(self, *args: Any, panel: str | None = None, **kwargs: Any) -> None:
189301
super().__init__(*args, **kwargs)
190302
self.panel = panel
191303

304+
def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
305+
_format_options(self, ctx, formatter)
306+
192307
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
193308
if not use_rich_style():
194309
return super().format_help(ctx, formatter)
@@ -210,6 +325,7 @@ class ModalGroup(click.Group):
210325

211326
command_class = ModalCommand
212327
group_class = type # nested @group.group() reuses the enclosing class
328+
defer_global_option_parsing = False
213329

214330
def __init__(self, *args: Any, panel: str | None = None, **kwargs: Any) -> None:
215331
# Default to showing help when a group is invoked with no subcommand.
@@ -232,6 +348,29 @@ def add_command(
232348
if hidden is not None:
233349
cmd.hidden = hidden
234350

351+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
352+
index = 0
353+
while index < len(args):
354+
arg = args[index]
355+
if global_option_length := _global_option_token_length(ctx, args, index):
356+
index += global_option_length
357+
continue
358+
command = self.commands.get(arg)
359+
if command and getattr(command, "defer_global_option_parsing", False):
360+
args[:] = _consume_global_options(ctx, args[:index]) + args[index:]
361+
break
362+
index += 1
363+
else:
364+
args[:] = _consume_global_options(ctx, args)
365+
366+
return super().parse_args(ctx, args)
367+
368+
def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
369+
if _has_visible_commands(self):
370+
self.format_commands(ctx, formatter)
371+
else:
372+
_format_options(self, ctx, formatter)
373+
235374
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
236375
# Replaces click's single flat "Commands:" section with one section per
237376
# panel so the simple-style help output still preserves grouping.
@@ -249,7 +388,7 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non
249388
[
250389
_build_usage(self, ctx),
251390
_build_help_text(self),
252-
_build_options(self, ctx),
391+
None if _has_visible_commands(self) else _build_options(self, ctx),
253392
_build_commands(self, _available_width(console)),
254393
_build_epilog(self),
255394
],

py/modal/cli/config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
import click
55

6-
from modal.config import _profile, _store_user_config, config
6+
from modal import config as config_module
7+
from modal.config import _store_user_config, config
78
from modal.environments import Environment
89
from modal.output import OutputManager
910

@@ -46,7 +47,7 @@ def set_environment(environment_name: str):
4647
# Confirm that the environment exists by looking it up
4748
Environment.from_name(environment_name).hydrate()
4849
_store_user_config({"environment": environment_name})
49-
click.echo(f"New default environment for profile {_profile}: {environment_name}")
50+
click.echo(f"New default environment for profile {config_module._profile}: {environment_name}")
5051

5152

5253
@config_cli.command("set", hidden=True, no_args_is_help=True)

py/modal/cli/entry_point.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from modal.output import OutputManager
99

1010
from . import run, shell as shell_module
11-
from ._help import ModalCommand, ModalGroup
11+
from ._help import ModalCommand, ModalGroup, ModalProfileOption
1212
from .app import app_cli
1313
from .billing import billing_cli
1414
from .bootstrap import bootstrap
@@ -52,6 +52,12 @@
5252
expose_value=False,
5353
callback=lambda ctx, param, value: _version_callback(ctx, value),
5454
)
55+
@click.option(
56+
"--profile",
57+
cls=ModalProfileOption,
58+
environment_variable="MODAL_PROFILE",
59+
help="Use this Modal profile for the command.",
60+
)
5561
def entrypoint_cli():
5662
pass
5763

@@ -88,14 +94,13 @@ def check_path():
8894

8995

9096
@click.command("setup", cls=ModalCommand, help="Bootstrap Modal's configuration.")
91-
@click.option("--profile", default=None)
9297
@synchronizer.create_blocking
93-
async def setup(profile: str | None = None):
98+
async def setup():
9499
check_path()
95100
print_logo()
96101

97102
# Fetch a new token (same as `modal token new` but redirect to /home once finishes)
98-
await _new_token(profile=profile, next_url="/home")
103+
await _new_token(next_url="/home")
99104

100105
output = OutputManager.get()
101106
output.print("[green]→[/green] Run [bold]modal skills install[/bold] to install agent skills")

py/modal/cli/profile.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
from rich.json import JSON
99
from rich.table import Table
1010

11+
from modal import config as config_module
1112
from modal._utils.async_utils import synchronizer
1213
from modal.config import (
1314
Config,
1415
_lookup_workspace,
15-
_profile,
1616
config_profiles,
1717
config_set_active_profile,
1818
)
@@ -41,7 +41,7 @@ def activate(profile: str):
4141

4242
@profile_cli.command("current", help="Print the currently active Modal profile.")
4343
def current():
44-
click.echo(_profile)
44+
click.echo(config_module._profile)
4545

4646

4747
@profile_cli.command("list", help="Show all Modal profiles and highlight the active one.")
@@ -62,7 +62,7 @@ async def list_(json: bool | None = False):
6262

6363
rows = []
6464
for profile, resp in zip(profiles, responses):
65-
active = profile == _profile
65+
active = profile == config_module._profile
6666
if isinstance(resp, AuthError):
6767
workspace = "Unknown (authentication failure)"
6868
elif isinstance(resp, TimeoutError):
@@ -80,7 +80,7 @@ async def list_(json: bool | None = False):
8080
if "MODAL_TOKEN_ID" in os.environ:
8181
try:
8282
env_based_resp = await _lookup_workspace(
83-
config.get("server_url", profile=_profile),
83+
config.get("server_url", profile=config_module._profile),
8484
os.environ["MODAL_TOKEN_ID"],
8585
os.environ.get("MODAL_TOKEN_SECRET", ""),
8686
)

py/modal/cli/run.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from ..output import OutputManager
2424
from ..runner import DEPLOYMENT_STRATEGY_TYPE, deploy_app, run_app
2525
from ..serving import serve_app
26-
from ._help import ModalCommand, ModalGroup
26+
from ._help import ModalCommand, ModalGroup, _consume_global_options, _global_option_token_length
2727
from .import_refs import (
2828
CLICommand,
2929
MethodReference,
@@ -411,6 +411,33 @@ def _get_runnable_list(all_usable_commands: list[CLICommand]) -> str:
411411
class RunGroup(ModalGroup):
412412
"""Click group that resolves subcommands dynamically from a file/module ref."""
413413

414+
defer_global_option_parsing = True
415+
416+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
417+
option_params: dict[str, click.Option] = {}
418+
for param in self.get_params(ctx):
419+
if isinstance(param, click.Option):
420+
for option in (*param.opts, *param.secondary_opts):
421+
option_params[option] = param
422+
423+
func_ref_index = 0
424+
while func_ref_index < len(args):
425+
arg = args[func_ref_index]
426+
if global_option_length := _global_option_token_length(ctx, args, func_ref_index):
427+
func_ref_index += global_option_length
428+
elif arg == "--":
429+
func_ref_index += 1
430+
break
431+
elif param := option_params.get(arg.split("=", 1)[0]):
432+
func_ref_index += 1 if "=" in arg or param.is_flag else param.nargs + 1
433+
elif arg.startswith("-"):
434+
func_ref_index += 1
435+
else:
436+
break
437+
438+
args[:] = _consume_global_options(ctx, args[:func_ref_index]) + args[func_ref_index:]
439+
return click.Group.parse_args(self, ctx, args)
440+
414441
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
415442
# note: get_command here is run before the "group logic" in the `run` logic below
416443
# so to ensure that `env` has been globally populated before user code is loaded, it

0 commit comments

Comments
 (0)