@@ -86,9 +86,10 @@ def _print_cli_exception(*, exc: BaseException) -> None:
8686Requires a running PyRIT backend server. Use 'start-server' to launch one,
8787or connect to an existing server with --server-url.
8888
89- Global options (usable with any command, before or after the verb):
90- --server-url --config-file --log-level --request-timeout --start-server --startup-timeout
91- Run 'pyrit_scan <command> --help' for full option descriptions and a command's arguments.
89+ Global options (--server-url, --config-file, --log-level) are listed below and
90+ work before or after the verb. Backend commands (run, list-*, add-initializer,
91+ scenario-results, scenario-history) also accept --start-server, --startup-timeout,
92+ and --request-timeout; run 'pyrit_scan <command> --help' to see them.
9293
9394Examples:
9495 # Start the backend server
@@ -137,61 +138,113 @@ def _positive_finite_float(value: str) -> float:
137138 return parsed
138139
139140
140- def _build_global_parser () -> ArgumentParser :
141+ _SERVER_URL_HELP = "URL of the PyRIT backend server (default: http://localhost:8000)"
142+ _LOG_LEVEL_HELP = "Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) (default: WARNING)"
143+ # Scan-specific override of the shared CONFIG_FILE_HELP: for the thin client the file's
144+ # database/initializers/init-scripts/env sections only take effect when a backend is
145+ # launched (start-server or --start-server) and are NOT re-applied to a running server.
146+ _CONFIG_FILE_HELP = (
147+ "Path to a YAML config file. For commands that talk to a running server this only "
148+ "selects which backend to connect to (server.url). Its database, initializers, "
149+ "initialization-scripts, and env sections apply only when a backend is launched "
150+ "(start-server or --start-server); they are not re-run against an already-running server."
151+ )
152+ _START_SERVER_HELP = "Start a local backend server first if one is not already running"
153+ _STARTUP_TIMEOUT_HELP = "Seconds to wait for a local backend to start (default: server.startup_timeout or 120)"
154+ _REQUEST_TIMEOUT_HELP = (
155+ "HTTP read timeout in seconds for non-polling server requests "
156+ "(catalog/results/cancel/etc). Defaults to 60. Polling a live "
157+ "scenario run always waits indefinitely regardless of this value."
158+ )
159+
160+
161+ def _add_common_options (* , parser : ArgumentParser , suppress_defaults : bool ) -> None :
141162 """
142- Build the parser holding options valid for *every* subcommand.
163+ Add the options that *every* command honors: server URL, config file, log level.
164+
165+ These live on the root parser (real defaults) *and* on each sub-parser
166+ (``SUPPRESS`` defaults, so a value parsed by the root — e.g.
167+ ``pyrit_scan --server-url X run`` — is not clobbered by the sub-parser's own
168+ default on the second parse pass).
169+
170+ Args:
171+ parser (ArgumentParser): Parser to extend.
172+ suppress_defaults (bool): Use ``argparse.SUPPRESS`` defaults (sub-parser copies)
173+ instead of real defaults (the root parser, which owns the canonical values).
174+ """
175+ default = argparse .SUPPRESS if suppress_defaults else None
176+ log_default = argparse .SUPPRESS if suppress_defaults else logging .WARNING
177+ group = parser .add_argument_group ("global options" )
178+ group .add_argument ("--server-url" , type = str , default = default , help = _SERVER_URL_HELP )
179+ group .add_argument ("--config-file" , type = Path , default = default , help = _CONFIG_FILE_HELP )
180+ group .add_argument ("--log-level" , type = validate_log_level_argparse , default = log_default , help = _LOG_LEVEL_HELP )
143181
144- This parser is never used on its own. It is passed as ``parents=[...]`` to
145- each verb's sub-parser so that global options work after any verb, e.g.
146- ``pyrit_scan run foo --server-url X`` or ``pyrit_scan list-scenarios
147- --server-url X`` .
182+
183+ def _build_common_parent () -> ArgumentParser :
184+ """
185+ Parent parser with the common options for a sub-parser (``SUPPRESS`` defaults) .
148186
149187 Returns:
150- ArgumentParser: A help-less parent parser with the global options.
188+ ArgumentParser: A help-less parent parser with the common options.
151189 """
152190 parser = ArgumentParser (add_help = False )
153- group = parser .add_argument_group ("global options" )
154- group .add_argument (
155- "--server-url" ,
156- type = str ,
157- help = "URL of the PyRIT backend server (default: http://localhost:8000)" ,
158- )
159- group .add_argument (
160- "--start-server" ,
161- action = "store_true" ,
162- help = "Start a local backend server first if one is not already running" ,
163- )
164- group .add_argument (
165- "--startup-timeout" ,
166- type = _positive_finite_float ,
167- default = None ,
168- metavar = "SECONDS" ,
169- help = "Seconds to wait for a local backend to start (default: server.startup_timeout or 120)" ,
170- )
171- group .add_argument (
172- "--config-file" ,
173- type = Path ,
174- help = ARG_HELP ["config_file" ],
175- )
191+ _add_common_options (parser = parser , suppress_defaults = True )
192+ return parser
193+
194+
195+ def _build_client_parent () -> ArgumentParser :
196+ """
197+ Parent parser for commands that reach the backend through the API client.
198+
199+ Adds ``--request-timeout`` plus the auto-start options (``--start-server`` /
200+ ``--startup-timeout``). Attached to ``run``, the ``list-*`` verbs,
201+ ``add-initializer``, ``scenario-results``, and ``scenario-history``. Verbs that do
202+ not open a client (``start-server``, ``stop-server``) deliberately omit it so
203+ unsupported combinations like ``start-server --request-timeout`` are rejected.
204+
205+ Returns:
206+ ArgumentParser: A help-less parent parser with the client/auto-start options.
207+ """
208+ parser = ArgumentParser (add_help = False )
209+ group = parser .add_argument_group ("server options" )
210+ group .add_argument ("--request-timeout" , type = float , default = None , help = _REQUEST_TIMEOUT_HELP )
211+ group .add_argument ("--start-server" , action = "store_true" , help = _START_SERVER_HELP )
176212 group .add_argument (
177- "--log-level" ,
178- type = validate_log_level_argparse ,
179- default = logging .WARNING ,
180- help = "Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) (default: WARNING)" ,
213+ "--startup-timeout" , type = _positive_finite_float , default = None , metavar = "SECONDS" , help = _STARTUP_TIMEOUT_HELP
181214 )
215+ return parser
216+
217+
218+ def _build_start_server_parent () -> ArgumentParser :
219+ """
220+ Parent parser for the ``start-server`` verb: only ``--startup-timeout`` applies.
221+
222+ Returns:
223+ ArgumentParser: A help-less parent parser with the startup timeout option.
224+ """
225+ parser = ArgumentParser (add_help = False )
226+ group = parser .add_argument_group ("server startup options" )
182227 group .add_argument (
183- "--request-timeout" ,
184- type = float ,
185- default = None ,
186- help = (
187- "HTTP read timeout in seconds for non-polling server requests "
188- "(catalog/results/cancel/etc). Defaults to 60. Polling a live "
189- "scenario run always waits indefinitely regardless of this value."
190- ),
228+ "--startup-timeout" , type = _positive_finite_float , default = None , metavar = "SECONDS" , help = _STARTUP_TIMEOUT_HELP
191229 )
192230 return parser
193231
194232
233+ def _build_global_parser () -> ArgumentParser :
234+ """
235+ Union of every option group.
236+
237+ Used *only* by the legacy shim to strip options and locate a verb; it is never
238+ attached to a command. Keeping it a union (rather than the per-command groups)
239+ lets the shim reorder any option placed before a verb, regardless of which
240+ command ultimately owns it.
241+
242+ Returns:
243+ ArgumentParser: A help-less parser recognizing all common and server options.
244+ """
245+ return ArgumentParser (add_help = False , parents = [_build_common_parent (), _build_client_parent ()])
246+
247+
195248def _add_run_arguments (* , parser : ArgumentParser , scenario_params : list [Parameter ] | None = None ) -> None :
196249 """
197250 Add the ``run`` verb's arguments (scenario positional + run flags) to *parser*.
@@ -260,29 +313,36 @@ def _build_parser(*, scenario_params: list[Parameter] | None = None, add_help: b
260313 Returns:
261314 ArgumentParser: The configured parser.
262315 """
263- global_parser = _build_global_parser ()
316+ common_parent = _build_common_parent ()
317+ client_parent = _build_client_parent ()
318+ start_server_parent = _build_start_server_parent ()
319+ client_parents = [common_parent , client_parent ]
320+
264321 parser = ArgumentParser (
265322 prog = "pyrit_scan" ,
266323 description = _DESCRIPTION ,
267324 formatter_class = RawDescriptionHelpFormatter ,
268325 add_help = add_help ,
269326 )
327+ # The root parser owns the real global options so top-level help and pre-verb
328+ # handling (e.g. ``pyrit_scan --server-url X --help``) work normally.
329+ _add_common_options (parser = parser , suppress_defaults = False )
270330 subparsers = parser .add_subparsers (dest = "command" , metavar = "<command>" , title = "commands" )
271331
272332 run_parser = subparsers .add_parser (
273333 "run" ,
274- parents = [ global_parser ] ,
334+ parents = client_parents ,
275335 help = "Run a scenario against a target" ,
276336 formatter_class = RawDescriptionHelpFormatter ,
277337 )
278338 _add_run_arguments (parser = run_parser , scenario_params = scenario_params )
279339
280340 for verb , help_text in _LIST_VERBS .items ():
281- subparsers .add_parser (verb , parents = [ global_parser ] , help = help_text )
341+ subparsers .add_parser (verb , parents = client_parents , help = help_text )
282342
283343 add_init_parser = subparsers .add_parser (
284344 "add-initializer" ,
285- parents = [ global_parser ] ,
345+ parents = client_parents ,
286346 help = "Register initializer(s) from Python script file(s)" ,
287347 )
288348 add_init_parser .add_argument (
@@ -295,15 +355,15 @@ def _build_parser(*, scenario_params: list[Parameter] | None = None, add_help: b
295355
296356 results_parser = subparsers .add_parser (
297357 "scenario-results" ,
298- parents = [ global_parser ] ,
358+ parents = client_parents ,
299359 help = "Inspect the results of a completed scenario run" ,
300360 )
301361 results_parser .add_argument ("scenario_result_id" , type = str , help = "Scenario result id to inspect" )
302362 add_results_arguments (parser = results_parser )
303363
304364 history_parser = subparsers .add_parser (
305365 "scenario-history" ,
306- parents = [ global_parser ] ,
366+ parents = client_parents ,
307367 help = "List recent scenario runs" ,
308368 )
309369 history_parser .add_argument (
@@ -315,16 +375,31 @@ def _build_parser(*, scenario_params: list[Parameter] | None = None, add_help: b
315375 help = "Number of recent runs to show (default: 10)" ,
316376 )
317377
318- subparsers .add_parser ("start-server" , parents = [global_parser ], help = "Start a local backend server" )
319- subparsers .add_parser ("stop-server" , parents = [global_parser ], help = "Stop the backend server" )
378+ subparsers .add_parser (
379+ "start-server" , parents = [common_parent , start_server_parent ], help = "Start a local backend server"
380+ )
381+ subparsers .add_parser ("stop-server" , parents = [common_parent ], help = "Stop the backend server" )
320382
321383 return parser
322384
323385
386+ def _discover_verbs () -> frozenset [str ]:
387+ """
388+ Read the registered subcommand verbs straight off the built parser's subparsers.
389+
390+ Returns:
391+ frozenset[str]: Every registered subcommand verb.
392+ """
393+ parser = _build_parser (add_help = False )
394+ for action in parser ._actions :
395+ if isinstance (action , argparse ._SubParsersAction ):
396+ return frozenset (action .choices )
397+ return frozenset ()
398+
399+
324400#: Every valid subcommand verb (used by the legacy-argv shim to detect new-style calls).
325- _KNOWN_VERBS : frozenset [str ] = frozenset (
326- {"run" , "add-initializer" , "scenario-results" , "scenario-history" , "start-server" , "stop-server" , * _LIST_VERBS }
327- )
401+ #: Derived from _build_parser so adding/renaming a subcommand can't leave this stale.
402+ _KNOWN_VERBS : frozenset [str ] = _discover_verbs ()
328403
329404
330405# Namespacing prefix for scenario-declared params on the parsed Namespace.
@@ -450,6 +525,7 @@ def _extract_scenario_args(*, parsed: Namespace) -> dict[str, Any]:
450525 "--list-datasets" : "list-datasets" ,
451526 "--add-initializer" : "add-initializer" ,
452527 "--stop-server" : "stop-server" ,
528+ "--scenario-results" : "scenario-results" ,
453529}
454530
455531
@@ -503,6 +579,11 @@ def _translate_legacy_argv(argv: list[str]) -> list[str]:
503579 verb = leftover [0 ]
504580 index = argv .index (verb )
505581 return [verb , * argv [:index ], * argv [index + 1 :]]
582+ if leftover [0 ] in ("-h" , "--help" ):
583+ # Global options followed by top-level help (e.g. ``--server-url X --help``).
584+ # Leave argv untouched so the root parser prints its own help instead of
585+ # misreading ``--help`` as an implicit scenario name.
586+ return argv
506587 # Otherwise it is a bare scenario name (+ run flags) → implicit run.
507588 _warn_legacy (old = "<scenario> (implicit run)" , new = "run <scenario>" )
508589 return ["run" , * argv ]
@@ -607,7 +688,7 @@ def _resolve_configured_server_url(*, parsed_args: Namespace) -> str:
607688
608689async def _handle_stop_server_async (* , parsed_args : Namespace ) -> int :
609690 """
610- Handle ``-- stop-server``: probe, then terminate the listening process.
691+ Handle ``stop-server``: probe, then terminate the listening process.
611692
612693 Returns:
613694 int: Zero when no server is running or shutdown succeeds; one otherwise.
@@ -662,7 +743,7 @@ async def _handle_list_commands_async(*, client: Any, parsed_args: Namespace) ->
662743
663744async def _handle_add_initializer_async (* , client : Any , parsed_args : Namespace ) -> int :
664745 """
665- Handle ``-- add-initializer``: upload one or more scripts to the server.
746+ Handle ``add-initializer``: upload one or more scripts to the server.
666747
667748 Returns:
668749 int: Exit code (``0`` on success, ``1`` on failure).
@@ -909,24 +990,13 @@ async def _run_scenario_async(
909990 return 1
910991
911992
912- async def _dispatch_with_client_async (* , client : Any , parsed_args : Namespace ) -> int :
993+ async def _handle_run_async (* , client : Any , parsed_args : Namespace ) -> int :
913994 """
914- Dispatch a verb that needs an open API client .
995+ Handle the ``run`` verb: resolve the scenario, reparse its declared flags, then run it .
915996
916997 Returns:
917- int: Exit code from the dispatched command .
998+ int: Exit code (``0`` if the run completed successfully, ``1`` otherwise) .
918999 """
919- command = parsed_args .command
920- if command in _LIST_VERBS :
921- return await _handle_list_commands_async (client = client , parsed_args = parsed_args )
922- if command == "add-initializer" :
923- return await _handle_add_initializer_async (client = client , parsed_args = parsed_args )
924- if command == "scenario-results" :
925- return await _handle_results_async (client = client , parsed_args = parsed_args )
926- if command == "scenario-history" :
927- return await _handle_scenario_history_async (client = client , parsed_args = parsed_args )
928-
929- # command == "run": the scenario positional is required by the run sub-parser.
9301000 scenario_name = parsed_args .scenario_name
9311001 scenario_meta = await client .get_scenario_async (scenario_name = scenario_name )
9321002 if scenario_meta is None :
@@ -943,9 +1013,31 @@ async def _dispatch_with_client_async(*, client: Any, parsed_args: Namespace) ->
9431013 )
9441014 if reparsed is None :
9451015 return 1
946- parsed_args = reparsed
9471016
948- return await _run_scenario_async (client = client , parsed_args = parsed_args , scenario_meta = scenario_meta )
1017+ return await _run_scenario_async (client = client , parsed_args = reparsed , scenario_meta = scenario_meta )
1018+
1019+
1020+ #: Post-client verbs, each a uniform ``(*, client, parsed_args) -> int`` handler. Reached
1021+ #: only after the API client is open (start-server/stop-server are handled earlier, before
1022+ #: any client exists), so dispatch here is a pure table lookup with no branching.
1023+ _CLIENT_HANDLERS : dict [str , Callable [..., Any ]] = {
1024+ "run" : _handle_run_async ,
1025+ "add-initializer" : _handle_add_initializer_async ,
1026+ "scenario-results" : _handle_results_async ,
1027+ "scenario-history" : _handle_scenario_history_async ,
1028+ ** dict .fromkeys (_LIST_VERBS , _handle_list_commands_async ),
1029+ }
1030+
1031+
1032+ async def _dispatch_with_client_async (* , client : Any , parsed_args : Namespace ) -> int :
1033+ """
1034+ Dispatch a verb that needs an open API client.
1035+
1036+ Returns:
1037+ int: Exit code from the dispatched command.
1038+ """
1039+ handler = _CLIENT_HANDLERS [parsed_args .command ]
1040+ return await handler (client = client , parsed_args = parsed_args )
9491041
9501042
9511043async def _run_async (* , parsed_args : Namespace ) -> int :
0 commit comments