Skip to content

Commit 87ba832

Browse files
committed
feat: add probe tier to --list_probes (#1571)
2 parents 32cd6e2 + fcbc6fd commit 87ba832

5 files changed

Lines changed: 132 additions & 18 deletions

File tree

garak/cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ def main(arguments=None) -> None:
226226
parser.add_argument(
227227
"--list_probes",
228228
action="store_true",
229-
help="list all available probes. Usage: combine with --probes/-p to filter for probes that will be activated based on a `probe_spec`, e.g. '--list_probes -p dan' to show only active 'dan' family probes.",
229+
help="list available probes. Use -v for a detailed markdown table with tier and description. "
230+
"Combine with --probes/-p to filter by probe_spec, e.g. '--list_probes -p dan'.",
230231
)
231232
parser.add_argument(
232233
"--list_detectors",
@@ -455,7 +456,7 @@ def worker_count_validation(workers):
455456
probe_spec = getattr(args, "probes", None)
456457
if probe_spec and probe_spec.lower() not in ("", "auto", "all", "*"):
457458
selected_probes, _ = _config.parse_plugin_spec(probe_spec, "probes")
458-
command.print_probes(selected_probes)
459+
command.print_probes(selected_probes, verbose=_config.system.verbose)
459460

460461
elif args.list_detectors:
461462
selected_detectors = None

garak/command.py

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def start_run():
4949

5050
logging.info("run started at %s", _config.transient.starttime_iso)
5151
# print("ASSIGN UUID", args)
52-
if _config.system.lite and "probes" not in _config.transient.cli_args and not _config.transient.cli_args.list_probes and not _config.transient.cli_args.list_detectors and not _config.transient.cli_args.list_generators and not _config.transient.cli_args.list_buffs and not _config.transient.cli_args.list_config and not _config.transient.cli_args.plugin_info and not _config.run.interactive: # type: ignore
52+
if _config.system.lite and "probes" not in _config.transient.cli_args and _config.transient.cli_args.list_probes is None and not _config.transient.cli_args.list_detectors and not _config.transient.cli_args.list_generators and not _config.transient.cli_args.list_buffs and not _config.transient.cli_args.list_config and not _config.transient.cli_args.plugin_info and not _config.run.interactive: # type: ignore
5353
hint(
5454
"The current/default config is optimised for speed rather than thoroughness. Try e.g. --config full for a stronger test, or specify some probes.",
5555
logging=logging,
@@ -159,17 +159,49 @@ def end_run():
159159
logging.info(msg)
160160

161161

162-
def print_plugins(prefix: str, color, selected_plugins=None):
162+
def _tier_name(tier_value):
163+
"""Convert a tier int value to its enum name string."""
164+
try:
165+
from garak.probes._tier import Tier
166+
return Tier(int(tier_value)).name
167+
except (ValueError, TypeError):
168+
return ""
169+
170+
171+
def _truncate(text, max_len=80):
172+
"""Truncate text to max_len, appending ellipsis if needed."""
173+
if len(text) > max_len:
174+
return text[:max_len - 1] + "…"
175+
return text
176+
177+
178+
# Column definitions per plugin type for verbose table output.
179+
# Each entry is (column_name, extractor_fn(info_dict) -> str).
180+
# "name" and "active" are always included and handled separately.
181+
_PLUGIN_TABLE_COLUMNS = {
182+
"probes": [
183+
("tier", lambda info: _tier_name(info.get("tier")) if info.get("tier") is not None else ""),
184+
("description", lambda info: _truncate(info.get("description", ""))),
185+
],
186+
# Future plugin types can define their own extra columns here, e.g.:
187+
# "detectors": [
188+
# ("description", lambda info: _truncate(info.get("description", ""))),
189+
# ],
190+
}
191+
192+
193+
def print_plugins(prefix: str, color, selected_plugins=None, verbose: int=0):
163194
"""
164195
Print plugins for a category (probes/detectors/generators/buffs).
165196
166197
Args:
167198
prefix: Plugin category (probes/detectors/generators/buffs)
168199
color: Color for output formatting
169200
selected_plugins: Optional list of specific plugins to show. If None, shows all.
201+
verbose: Verbosity level. 0 = plain list, >=1 = markdown table with metadata.
170202
"""
171203
from colorama import Style
172-
from garak._plugins import enumerate_plugins, PLUGIN_TYPES
204+
from garak._plugins import enumerate_plugins, plugin_info as get_plugin_info, PLUGIN_TYPES
173205

174206
if prefix not in PLUGIN_TYPES:
175207
raise ValueError(f"Requested prefix '{prefix}' is not a valid plugin type")
@@ -184,26 +216,75 @@ def print_plugins(prefix: str, color, selected_plugins=None):
184216
else:
185217
print(f"No {prefix} match the provided filter")
186218
return
187-
short = [(p.replace(f"{prefix}.", ""), a) for p, a in rows]
219+
220+
short = [(p.replace(f"{prefix}.", ""), a, p) for p, a, *_ in [(pn, ac, pn) for pn, ac in rows]]
188221
if selected_plugins is None:
189-
module_names = set([(m.split(".")[0], True) for m, a in short])
222+
module_names = {(m.split(".")[0], True, None) for m, a, _ in short}
190223
short += module_names
191224

192-
# print output
193-
for plugin_name, active in sorted(short):
194-
print(f"{Style.BRIGHT}{color}{prefix}: {Style.RESET_ALL}", end="")
195-
print(plugin_name, end="")
196-
if "." not in plugin_name:
197-
print(" 🌟", end="")
198-
if not active:
199-
print(" 💤", end="")
200-
print()
225+
sorted_items = sorted(short, key=lambda x: x[0])
201226

227+
if verbose >= 1 and prefix in _PLUGIN_TABLE_COLUMNS:
228+
_print_plugins_table(sorted_items, prefix)
229+
else:
230+
# plain text output (default)
231+
for item in sorted_items:
232+
plugin_name, active = item[0], item[1]
233+
print(f"{Style.BRIGHT}{color}{prefix}: {Style.RESET_ALL}", end="")
234+
print(plugin_name, end="")
235+
if "." not in plugin_name:
236+
print(" 🌟", end="")
237+
if not active:
238+
print(" 💤", end="")
239+
print()
240+
241+
242+
def _print_plugins_table(sorted_items, prefix):
243+
"""Render plugins as a markdown table with name, active, and type-specific columns."""
244+
from py_markdown_table.markdown_table import markdown_table
245+
from garak._plugins import plugin_info as get_plugin_info
246+
247+
extra_columns = _PLUGIN_TABLE_COLUMNS.get(prefix, [])
248+
249+
table_data = []
250+
for item in sorted_items:
251+
plugin_name, active = item[0], item[1]
252+
full_name = item[2] if len(item) > 2 else None
253+
254+
is_module_header = "." not in plugin_name
255+
256+
row = {"name": plugin_name}
257+
258+
if is_module_header:
259+
row["active"] = "🌟"
260+
for col_name, _ in extra_columns:
261+
row[col_name] = ""
262+
else:
263+
row["active"] = "✅" if active else "💤"
264+
info = get_plugin_info(full_name) if full_name else {}
265+
for col_name, extractor in extra_columns:
266+
row[col_name] = extractor(info)
267+
268+
table_data.append(row)
269+
270+
print(f"{prefix}:")
271+
print(
272+
markdown_table(table_data)
273+
.set_params(row_sep="markdown", padding_width=1, padding_weight="centerleft", quote=False)
274+
.get_markdown()
275+
)
202276

203-
def print_probes(selected_probes=None):
277+
278+
def print_probes(selected_probes=None, verbose=0):
279+
"""Print available probes.
280+
281+
Args:
282+
selected_probes: Optional list of specific probes to show.
283+
verbose: Verbosity level. 0 = plain list, >=1 = markdown table.
284+
"""
204285
from colorama import Fore
205286

206-
print_plugins("probes", Fore.LIGHTYELLOW_EX, selected_probes)
287+
print_plugins("probes", Fore.LIGHTYELLOW_EX, selected_probes, verbose=verbose)
207288

208289

209290
def print_detectors(selected_detectors=None):

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ dependencies = [
131131
"ftfy>=6.3.1",
132132
"websockets>=13.0",
133133
"boto3>=1.28.0",
134+
"py-markdown-table>=1.2.0",
134135
]
135136

136137
[project.optional-dependencies]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pillow>=10.4.0
4242
ftfy>=6.3.1
4343
websockets>=13.0
4444
boto3>=1.28.0
45+
py-markdown-table>=1.2.0
4546
# tests
4647
pytest>=8.0
4748
pytest-mock>=3.14.0

tests/cli/test_cli_list_filtering.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,33 @@ def test_list_probes_with_detector_spec(capsys, options):
6868
), "expected all spec values to be present"
6969
else:
7070
assert any("🌟" in ln for ln in lines)
71+
72+
73+
def test_list_probes_verbose_table(capsys):
74+
"""Test that --list_probes -v outputs a markdown table with tier and description."""
75+
cli.main(["--list_probes", "-v"])
76+
output = capsys.readouterr().out
77+
# Should contain markdown table structure
78+
assert "|" in output, "expected markdown table with | delimiters"
79+
# Should contain the expected column headers
80+
assert "name" in output, "expected 'name' column header"
81+
assert "active" in output, "expected 'active' column header"
82+
assert "tier" in output, "expected 'tier' column header"
83+
assert "description" in output, "expected 'description' column header"
84+
# Should contain at least one tier enum name
85+
tier_names = ["OF_CONCERN", "COMPETE_WITH_SOTA", "INFORMATIONAL", "UNLISTED"]
86+
assert any(
87+
name in output for name in tier_names
88+
), f"expected at least one tier name from {tier_names}"
89+
# Should contain active/inactive markers
90+
assert "✅" in output or "💤" in output, "expected active/inactive markers"
91+
# Module headers should have 🌟
92+
assert "🌟" in output, "expected module header markers"
93+
94+
95+
def test_list_probes_verbose_with_probe_spec(capsys):
96+
"""Test that --list_probes -v -p <spec> outputs a filtered markdown table."""
97+
cli.main(["--list_probes", "-v", "-p", "dan"])
98+
output = capsys.readouterr().out
99+
assert "|" in output, "expected markdown table"
100+
assert "dan" in output.lower(), "expected 'dan' probes in output"

0 commit comments

Comments
 (0)