77import os
88import shutil
99import sys
10+ from collections .abc import Sequence
1011from typing import Any
1112
1213import click
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+
31109def 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
83161def _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+
101209def _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+
117229def _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 ],
0 commit comments