Skip to content

Commit 2a97244

Browse files
rlundeen2Copilot
andauthored
MAINT: Refactor printers into lightweight and flexible output module (#1732)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9bc03c0 commit 2a97244

159 files changed

Lines changed: 8324 additions & 4039 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
applyTo: "pyrit/output/**"
3+
---
4+
5+
# PyRIT Output Module — Coding & Review Guidelines
6+
7+
For full architecture documentation, usage examples, and extension guides, see [doc/code/output/0_output.py](../../../doc/code/output/0_output.py).
8+
9+
This file covers the rules for **writing and reviewing** code in `pyrit/output/`.
10+
11+
## Critical Rules
12+
13+
### Output goes through the sink — never call `print()` directly
14+
15+
All rendering methods return `str`. The inherited `write_async` calls `render_async` then `_write_async(content)`. No bare `print()` calls anywhere in the output module except inside `StdoutSink`.
16+
17+
When reviewing: reject any `print()` call outside `StdoutSink`.
18+
19+
### Data fetching belongs in leaf classes only
20+
21+
Format classes (`PrettyAttackResultPrinter`, `MarkdownAttackResultPrinter`) must not import or reference `CentralMemory`. Only `*MemoryPrinter` leaf classes do data I/O.
22+
23+
When reviewing: reject any `CentralMemory` import in a non-leaf file (`pretty.py`, `markdown.py`, `json.py`).
24+
25+
### Sinks must use async I/O
26+
27+
Sink implementations must not block the event loop. Use `asyncio.to_thread()` or native async libraries for I/O operations. `FileSink` uses an `asyncio.Lock` to prevent concurrent write races.
28+
29+
When reviewing: reject synchronous `open()`, `write()`, or network calls inside a sink's `write_async`.
30+
31+
## Three-Layer Hierarchy
32+
33+
Every domain follows this structure. Do not mix responsibilities across layers.
34+
35+
| Layer | File | Responsibility | May import CentralMemory? |
36+
|-------|------|---------------|---------------------------|
37+
| **Base** | `base.py` | Abstract data-fetching methods + abstract `render_async` | No |
38+
| **Format** | `pretty.py`, `markdown.py`, `json.py` | Implements `render_async`, returns `str` | No |
39+
| **Leaf** | Same file as format (e.g., `PrettyAttackResultMemoryPrinter`) | Implements data methods via CentralMemory; forwarding `render_async` | Yes |
40+
41+
### File names = output format
42+
43+
- `pretty.py` — ANSI-colored human-readable
44+
- `markdown.py` — Markdown
45+
- `json.py` — structured JSON
46+
47+
### Memory leaf classes must work with zero args
48+
49+
```python
50+
printer = PrettyAttackResultMemoryPrinter() # defaults: StdoutSink, matching sub-printers
51+
await printer.write_async(result)
52+
```
53+
54+
Pass `sink=` to redirect output. Pass sub-printers only to override defaults.
55+
56+
### Convenience functions live in `helpers.py`
57+
58+
Every new domain printer **must** have a corresponding convenience function added to `helpers.py`. This is the primary entry point most callers use.
59+
60+
```python
61+
from pyrit.output.helpers import output_attack_async
62+
await output_attack_async(result, format="pretty")
63+
```
64+
65+
`helpers.py` resolves `format` → printer class, `sink` → Sink, and calls `write_async`.
66+
67+
When reviewing: if a new domain printer is added without a helper function, request one.

doc/code/auxiliary_attacks/0_auxiliary_attacks.ipynb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
"source": [
6565
"from pyrit.executor.attack import (\n",
6666
" AttackScoringConfig,\n",
67-
" ConsoleAttackResultPrinter,\n",
6867
" PromptSendingAttack,\n",
6968
")\n",
7069
"from pyrit.prompt_target import OpenAIChatTarget\n",
@@ -83,8 +82,7 @@
8382
"attack = PromptSendingAttack(objective_target=target, attack_scoring_config=scoring_config)\n",
8483
"result = await attack.execute_async(objective=objective) # type: ignore\n",
8584
"\n",
86-
"printer = ConsoleAttackResultPrinter()\n",
87-
"await printer.print_conversation_async(result=result) # type: ignore"
85+
"await output_attack_async(result)"
8886
]
8987
},
9088
{
@@ -180,7 +178,7 @@
180178
")\n",
181179
"\n",
182180
"result = await attack.execute_async(objective=objective) # type: ignore\n",
183-
"await printer.print_result_async(result=result) # type: ignore"
181+
"await output_attack_async(result)"
184182
]
185183
}
186184
],

doc/code/auxiliary_attacks/0_auxiliary_attacks.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,24 @@
88
# format_version: '1.3'
99
# jupytext_version: 1.17.3
1010
# ---
11-
1211
# %% [markdown]
1312
# # Auxiliary Attacks
14-
1513
# %% [markdown]
1614
# Auxiliary attacks cover a variety of techniques that do not fit into the core PyRIT functionality.
1715
#
1816
# These attack pipelines may be useful to run before orchestrating other attacks. For example, we provide an Azure Machine Learning (AML) pipeline for generating suffixes using the greedy coordinate gradient (GCG) [@zou2023gcg] algorithm.
19-
2017
# %% [markdown]
2118
# ## GCG Suffixes
22-
2319
# %% [markdown]
2420
# The [GCG demo notebook](1_gcg_azure_ml.ipynb) shows how to create an AML environment and submit a job that generates GCG suffixes, which can be appended to a base prompt to jailbreak a language model. In the example below, we compare the response generated by Phi-3-mini with and without a GCG suffix trained on that model.
2521
#
2622
# First, we send a harmful prompt to Phi-3-mini without a GCG suffix. If the environment variables `PHI3_MINI_ENDPOINT` and `PHI3_MINI_KEY` are not set in your .env file, the target will default to the model with `AZURE_ML_MANAGED_ENDPOINT` and `AZURE_ML_MANAGED_KEY`.
27-
2823
# %%
2924
from pyrit.executor.attack import (
3025
AttackScoringConfig,
31-
ConsoleAttackResultPrinter,
3226
PromptSendingAttack,
3327
)
28+
from pyrit.output import output_attack_async
3429
from pyrit.prompt_target import OpenAIChatTarget
3530
from pyrit.score import SelfAskRefusalScorer, TrueFalseInverterScorer
3631
from pyrit.setup import IN_MEMORY, initialize_pyrit_async
@@ -47,8 +42,7 @@
4742
attack = PromptSendingAttack(objective_target=target, attack_scoring_config=scoring_config)
4843
result = await attack.execute_async(objective=objective) # type: ignore
4944

50-
printer = ConsoleAttackResultPrinter()
51-
await printer.print_conversation_async(result=result) # type: ignore
45+
await output_attack_async(result)
5246

5347
# %% [markdown]
5448
# Next, let's apply a GCG suffix trained on Phi-3-mini to the base prompt using the `SuffixAppendConverter`.
@@ -73,4 +67,4 @@
7367
)
7468

7569
result = await attack.execute_async(objective=objective) # type: ignore
76-
await printer.print_result_async(result=result) # type: ignore
70+
await output_attack_async(result)

doc/code/converters/0_converters.ipynb

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,6 @@
279279
"source": [
280280
"from pyrit.executor.attack import (\n",
281281
" AttackConverterConfig,\n",
282-
" ConsoleAttackResultPrinter,\n",
283282
" PromptSendingAttack,\n",
284283
")\n",
285284
"from pyrit.prompt_converter import StringJoinConverter, VariationConverter\n",
@@ -306,8 +305,7 @@
306305
"\n",
307306
"result = await attack.execute_async(objective=objective) # type: ignore\n",
308307
"\n",
309-
"printer = ConsoleAttackResultPrinter()\n",
310-
"await printer.print_conversation_async(result=result) # type: ignore"
308+
"await output_attack_async(result)"
311309
]
312310
},
313311
{
@@ -370,7 +368,6 @@
370368
"source": [
371369
"from pyrit.executor.attack import (\n",
372370
" AttackConverterConfig,\n",
373-
" ConsoleAttackResultPrinter,\n",
374371
" PromptSendingAttack,\n",
375372
")\n",
376373
"from pyrit.prompt_converter import TranslationConverter\n",
@@ -407,8 +404,7 @@
407404
"result = await attack.execute_async(objective=objective) # type: ignore\n",
408405
"\n",
409406
"# Print the conversation showing both original and converted values\n",
410-
"printer = ConsoleAttackResultPrinter()\n",
411-
"await printer.print_conversation_async(result=result) # type: ignore"
407+
"await output_attack_async(result)"
412408
]
413409
}
414410
],

doc/code/converters/0_converters.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@
88
# format_version: '1.3'
99
# jupytext_version: 1.19.1
1010
# ---
11-
1211
# %% [markdown]
1312
# # Converters
14-
1513
# %% [markdown]
1614
# Converters are used to transform prompts before sending them to the target.
1715
#
@@ -24,10 +22,10 @@
2422
# ## Converter Modality Reference Table
2523
#
2624
# The following table shows all available converters organized by their input and output modalities:
27-
2825
# %%
2926
import pandas as pd
3027

28+
from pyrit.output import output_attack_async
3129
from pyrit.prompt_converter import get_converter_modalities
3230
from pyrit.setup import IN_MEMORY, initialize_pyrit_async
3331

@@ -102,7 +100,6 @@
102100
# %%
103101
from pyrit.executor.attack import (
104102
AttackConverterConfig,
105-
ConsoleAttackResultPrinter,
106103
PromptSendingAttack,
107104
)
108105
from pyrit.prompt_converter import StringJoinConverter, VariationConverter
@@ -129,8 +126,7 @@
129126

130127
result = await attack.execute_async(objective=objective) # type: ignore
131128

132-
printer = ConsoleAttackResultPrinter()
133-
await printer.print_conversation_async(result=result) # type: ignore
129+
await output_attack_async(result)
134130

135131
# %% [markdown]
136132
# ## Response Converters
@@ -154,7 +150,6 @@
154150
# %%
155151
from pyrit.executor.attack import (
156152
AttackConverterConfig,
157-
ConsoleAttackResultPrinter,
158153
PromptSendingAttack,
159154
)
160155
from pyrit.prompt_converter import TranslationConverter
@@ -191,5 +186,4 @@
191186
result = await attack.execute_async(objective=objective) # type: ignore
192187

193188
# Print the conversation showing both original and converted values
194-
printer = ConsoleAttackResultPrinter()
195-
await printer.print_conversation_async(result=result) # type: ignore
189+
await output_attack_async(result)

doc/code/converters/5_file_converters.ipynb

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@
9090
"from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH\n",
9191
"from pyrit.executor.attack import (\n",
9292
" AttackConverterConfig,\n",
93-
" ConsoleAttackResultPrinter,\n",
9493
" PromptSendingAttack,\n",
9594
")\n",
9695
"from pyrit.models import SeedPrompt\n",
@@ -147,7 +146,7 @@
147146
")\n",
148147
"\n",
149148
"result = await attack.execute_async(objective=prompt) # type: ignore\n",
150-
"await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore"
149+
"await output_attack_async(result)"
151150
]
152151
},
153152
{
@@ -226,7 +225,7 @@
226225
")\n",
227226
"\n",
228227
"result = await attack.execute_async(objective=prompt) # type: ignore\n",
229-
"await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore"
228+
"await output_attack_async(result)"
230229
]
231230
},
232231
{
@@ -363,7 +362,7 @@
363362
")\n",
364363
"\n",
365364
"result = await attack.execute_async(objective=\"Modify existing PDF\") # type: ignore\n",
366-
"await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore"
365+
"await output_attack_async(result)"
367366
]
368367
},
369368
{
@@ -445,7 +444,7 @@
445444
")\n",
446445
"\n",
447446
"result = await attack.execute_async(objective=\"This is a simple test string for Word document generation.\") # type: ignore\n",
448-
"await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore"
447+
"await output_attack_async(result)"
449448
]
450449
},
451450
{
@@ -529,7 +528,7 @@
529528
")\n",
530529
"\n",
531530
"result = await attack.execute_async(objective=\"AI Red Team Engineer\") # type: ignore\n",
532-
"await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore"
531+
"await output_attack_async(result)"
533532
]
534533
}
535534
],

doc/code/converters/5_file_converters.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
# format_version: '1.3'
99
# jupytext_version: 1.17.3
1010
# ---
11-
1211
# %% [markdown]
1312
# # 5. File Converters
1413
#
@@ -20,7 +19,6 @@
2019
#
2120
# - **PDFConverter**: Convert text to PDF documents with templates or direct generation
2221
# - **WordDocConverter**: Convert text to Word (.docx) documents with optional placeholder injection
23-
2422
# %% [markdown]
2523
# ## PDFConverter
2624
#
@@ -29,22 +27,20 @@
2927
# 1. **Template-Based PDF Generation**: Use YAML templates to render dynamic content into PDFs
3028
# 2. **Direct Prompt PDF Generation**: Convert plain text strings into PDFs without templates
3129
# 3. **Modify Existing PDFs**: Inject text into existing PDF documents
32-
3330
# %% [markdown]
3431
# ### Template-Based PDF Generation
3532
#
3633
# This mode populates placeholders in a YAML-based template and converts the rendered content into a PDF.
37-
3834
# %%
3935
import pathlib
4036

4137
from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH
4238
from pyrit.executor.attack import (
4339
AttackConverterConfig,
44-
ConsoleAttackResultPrinter,
4540
PromptSendingAttack,
4641
)
4742
from pyrit.models import SeedPrompt
43+
from pyrit.output import output_attack_async
4844
from pyrit.prompt_converter import PDFConverter
4945
from pyrit.prompt_normalizer import PromptConverterConfiguration
5046
from pyrit.prompt_target import TextTarget
@@ -98,7 +94,7 @@
9894
)
9995

10096
result = await attack.execute_async(objective=prompt) # type: ignore
101-
await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore
97+
await output_attack_async(result)
10298

10399
# %% [markdown]
104100
# ### Direct Prompt PDF Generation (No Template)
@@ -133,7 +129,7 @@
133129
)
134130

135131
result = await attack.execute_async(objective=prompt) # type: ignore
136-
await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore
132+
await output_attack_async(result)
137133

138134
# %% [markdown]
139135
# ### Modifying Existing PDFs with Injection Items
@@ -205,7 +201,7 @@
205201
)
206202

207203
result = await attack.execute_async(objective="Modify existing PDF") # type: ignore
208-
await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore
204+
await output_attack_async(result)
209205

210206
# %% [markdown]
211207
# ## WordDocConverter
@@ -238,7 +234,7 @@
238234
)
239235

240236
result = await attack.execute_async(objective="This is a simple test string for Word document generation.") # type: ignore
241-
await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore
237+
await output_attack_async(result)
242238

243239
# %% [markdown]
244240
# ### Placeholder-Based Injection into Existing Word Documents
@@ -278,4 +274,4 @@
278274
)
279275

280276
result = await attack.execute_async(objective="AI Red Team Engineer") # type: ignore
281-
await ConsoleAttackResultPrinter().print_conversation_async(result=result) # type: ignore
277+
await output_attack_async(result)

0 commit comments

Comments
 (0)