Skip to content

Commit 34adcd0

Browse files
committed
pr feedback
1 parent 3579c0c commit 34adcd0

3 files changed

Lines changed: 204 additions & 74 deletions

File tree

pyrit/cli/pyrit_scan.py

Lines changed: 166 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ def _print_cli_exception(*, exc: BaseException) -> None:
8686
Requires a running PyRIT backend server. Use 'start-server' to launch one,
8787
or 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
9394
Examples:
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+
195248
def _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

608689
async 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

663744
async 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

9511043
async def _run_async(*, parsed_args: Namespace) -> int:

pyrit/cli/pyrit_shell.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class PyRITShell(cmd.Cmd):
7171
list-initializers - List all available initializers
7272
list-targets - List all available targets
7373
list-converters - List all registered converter instances
74+
add-initializer <file>... - Register initializer(s) from Python script file(s)
7475
run <scenario> [opts] - Run a scenario with optional parameters
7576
scenario-history [N] - List the last N (default 10) scenario runs
7677
scenario-results [id] - Inspect a run: --view overview|attacks

0 commit comments

Comments
 (0)