Skip to content

Commit e677567

Browse files
FEAT: deprecate raise in PrependedConversationConfig (#1731)
1 parent 42c5cde commit e677567

4 files changed

Lines changed: 145 additions & 54 deletions

File tree

pyrit/executor/attack/component/conversation_manager.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,10 @@ async def initialize_context_async(
308308
- All messages get new UUIDs
309309
310310
For non-chat PromptTarget:
311-
- If `config.non_chat_target_behavior="normalize_first_turn"`: normalizes
312-
conversation to string and prepends to context.next_message
313-
- If `config.non_chat_target_behavior="raise"`: raises ValueError
311+
- Normalizes the prepended conversation to a string and prepends it to
312+
``context.next_message`` (using ``config.message_normalizer`` when provided).
313+
- If the deprecated ``config.non_chat_target_behavior="raise"`` is set,
314+
raises ValueError instead. This option is deprecated and will be removed in v0.16.0.
314315
315316
Args:
316317
context: The attack context to initialize.
@@ -390,11 +391,11 @@ async def _handle_non_chat_target_async(
390391
if config.non_chat_target_behavior == "raise":
391392
raise ValueError(
392393
"prepended_conversation requires the objective target to support multi-turn "
393-
"conversations with editable history. The current target does not. "
394-
"Use PrependedConversationConfig with non_chat_target_behavior='normalize_first_turn' "
395-
"to normalize the conversation into the first message instead."
394+
"conversations with editable history. The current target does not. Note that "
395+
"the non_chat_target_behavior parameter is deprecated and will be removed in "
396+
"v0.16.0; non-chat targets will then always normalize the prepended conversation "
397+
"into the first turn."
396398
)
397-
398399
# Normalize conversation to string
399400
normalizer = config.get_message_normalizer()
400401
normalized_context = await normalizer.normalize_string_async(prepended_conversation)

pyrit/executor/attack/component/prepended_conversation_config.py

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
from __future__ import annotations
55

6+
import warnings
67
from dataclasses import dataclass, field
7-
from typing import Literal, Optional, get_args
8+
from typing import Literal, get_args
89

10+
from pyrit.common.deprecation import print_deprecation_message
911
from pyrit.message_normalizer import (
1012
ConversationContextNormalizer,
1113
MessageStringNormalizer,
@@ -34,16 +36,25 @@ class PrependedConversationConfig:
3436
# Must implement MessageStringNormalizer (e.g., TokenizerTemplateNormalizer or ConversationContextNormalizer).
3537
# When None and normalization is needed (e.g., for non-chat targets), a default
3638
# ConversationContextNormalizer is used that produces "Turn N: User/Assistant" format.
37-
message_normalizer: Optional[MessageStringNormalizer] = None
38-
39-
# Behavior when the target is a PromptTarget but not a chat-capable PromptTarget:
40-
# - "normalize_first_turn": Normalize the prepended conversation into a string and
41-
# store it in ConversationState.normalized_prepended_context. This context will be
42-
# prepended to the first message sent to the target. Uses objective_target_context_normalizer
43-
# if provided, otherwise falls back to ConversationContextNormalizer.
44-
# - "raise": Raise a ValueError. Use this when prepended conversation history must be
45-
# maintained by the target (i.e., target must be a chat-capable PromptTarget).
46-
non_chat_target_behavior: Literal["normalize_first_turn", "raise"] = "normalize_first_turn"
39+
message_normalizer: MessageStringNormalizer | None = None
40+
41+
# Deprecated: this option will be removed in v0.16.0. Setting this field to any
42+
# non-None value emits a DeprecationWarning. In this release, ``"raise"`` still
43+
# raises ValueError on non-chat targets; ``"normalize_first_turn"`` and ``None``
44+
# both normalize the prepended conversation into the first turn (via
45+
# ``message_normalizer``; default: ConversationContextNormalizer). In v0.16.0
46+
# non-chat targets will always normalize; there is no replacement for the
47+
# ``"raise"`` behavior.
48+
non_chat_target_behavior: Literal["normalize_first_turn", "raise"] | None = None
49+
50+
def __post_init__(self) -> None:
51+
"""Emit a DeprecationWarning when the deprecated ``non_chat_target_behavior`` field is set."""
52+
if self.non_chat_target_behavior is not None:
53+
print_deprecation_message(
54+
old_item="PrependedConversationConfig(non_chat_target_behavior=...)",
55+
new_item="PrependedConversationConfig() (non-chat targets always normalize the prepended conversation)",
56+
removed_in="0.16.0",
57+
)
4758

4859
def get_message_normalizer(self) -> MessageStringNormalizer:
4960
"""
@@ -58,30 +69,44 @@ def get_message_normalizer(self) -> MessageStringNormalizer:
5869
@classmethod
5970
def default(cls) -> PrependedConversationConfig:
6071
"""
61-
Create a default configuration with converters applied to all roles.
72+
Return a deprecated configuration with ``non_chat_target_behavior="raise"``.
73+
74+
.. deprecated::
75+
``default()`` is deprecated and will be removed in v0.16.0. Use
76+
``PrependedConversationConfig()`` instead. In this release the returned
77+
configuration still raises on non-chat targets; in v0.16.0 the ``"raise"``
78+
branch is removed and non-chat targets will always normalize the prepended
79+
conversation into the first turn.
6280
6381
Returns:
64-
A configuration that applies converters to all prepended messages,
65-
raising an error for non-chat targets.
82+
A configuration equivalent to ``PrependedConversationConfig(non_chat_target_behavior="raise")``.
6683
"""
67-
return cls(
68-
apply_converters_to_roles=list(get_args(ChatMessageRole)),
69-
message_normalizer=None,
70-
non_chat_target_behavior="raise",
84+
print_deprecation_message(
85+
old_item="PrependedConversationConfig.default()",
86+
new_item="PrependedConversationConfig() (non-chat targets always normalize the prepended conversation)",
87+
removed_in="0.16.0",
7188
)
89+
# Suppress the __post_init__ deprecation warning so callers see exactly
90+
# one warning (the one for default()) rather than two for a single deprecated call.
91+
with warnings.catch_warnings():
92+
warnings.simplefilter("ignore", DeprecationWarning)
93+
return cls(non_chat_target_behavior="raise")
7294

7395
@classmethod
7496
def for_non_chat_target(
7597
cls,
7698
*,
77-
message_normalizer: Optional[MessageStringNormalizer] = None,
78-
apply_converters_to_roles: Optional[list[ChatMessageRole]] = None,
99+
message_normalizer: MessageStringNormalizer | None = None,
100+
apply_converters_to_roles: list[ChatMessageRole] | None = None,
79101
) -> PrependedConversationConfig:
80102
"""
81103
Create a configuration for use with non-chat targets.
82104
83-
This configuration normalizes the prepended conversation into a text block
84-
that will be prepended to the first message sent to the target.
105+
.. deprecated::
106+
``for_non_chat_target()`` is deprecated and will be removed in v0.16.0.
107+
Non-chat targets always normalize the prepended conversation into the
108+
first turn, so this factory is equivalent to ``PrependedConversationConfig(...)``
109+
with the same arguments. Use the default constructor instead.
85110
86111
Args:
87112
message_normalizer: Normalizer for formatting the prepended conversation into a string.
@@ -92,10 +117,21 @@ def for_non_chat_target(
92117
Returns:
93118
A configuration that normalizes the prepended conversation for non-chat targets.
94119
"""
95-
return cls(
96-
apply_converters_to_roles=(
97-
apply_converters_to_roles if apply_converters_to_roles is not None else list(get_args(ChatMessageRole))
98-
),
99-
message_normalizer=message_normalizer,
100-
non_chat_target_behavior="normalize_first_turn",
120+
print_deprecation_message(
121+
old_item="PrependedConversationConfig.for_non_chat_target()",
122+
new_item="PrependedConversationConfig() (non-chat targets always normalize the prepended conversation)",
123+
removed_in="0.16.0",
101124
)
125+
# Suppress the __post_init__ deprecation warning so callers see exactly one
126+
# warning (the one for for_non_chat_target()) rather than two.
127+
with warnings.catch_warnings():
128+
warnings.simplefilter("ignore", DeprecationWarning)
129+
return cls(
130+
apply_converters_to_roles=(
131+
apply_converters_to_roles
132+
if apply_converters_to_roles is not None
133+
else list(get_args(ChatMessageRole))
134+
),
135+
message_normalizer=message_normalizer,
136+
non_chat_target_behavior="normalize_first_turn",
137+
)

tests/unit/executor/attack/component/test_conversation_manager.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,7 @@ async def test_normalizes_for_non_chat_target_when_configured(
838838
context.prepended_conversation = sample_conversation
839839
context.next_message = Message.from_prompt(prompt="Next message", role="user")
840840

841-
config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn")
841+
config = PrependedConversationConfig()
842842

843843
await manager.initialize_context_async(
844844
context=context,
@@ -1088,7 +1088,8 @@ async def test_non_chat_target_behavior_raise_explicit(
10881088
context = _TestAttackContext(params=AttackParameters(objective="Test objective"))
10891089
context.prepended_conversation = sample_conversation
10901090

1091-
config = PrependedConversationConfig(non_chat_target_behavior="raise")
1091+
with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"):
1092+
config = PrependedConversationConfig(non_chat_target_behavior="raise")
10921093

10931094
with pytest.raises(
10941095
ValueError,
@@ -1115,7 +1116,7 @@ async def test_non_chat_target_behavior_normalize_first_turn_creates_next_messag
11151116
context.prepended_conversation = sample_conversation
11161117
context.next_message = None
11171118

1118-
config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn")
1119+
config = PrependedConversationConfig()
11191120

11201121
await manager.initialize_context_async(
11211122
context=context,
@@ -1142,7 +1143,7 @@ async def test_non_chat_target_behavior_normalize_first_turn_prepends_to_existin
11421143
context.prepended_conversation = sample_conversation
11431144
context.next_message = Message.from_prompt(prompt="My question", role="user")
11441145

1145-
config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn")
1146+
config = PrependedConversationConfig()
11461147

11471148
await manager.initialize_context_async(
11481149
context=context,
@@ -1170,7 +1171,7 @@ async def test_non_chat_target_behavior_normalize_returns_empty_state(
11701171
context = _TestAttackContext(params=AttackParameters(objective="Test objective"))
11711172
context.prepended_conversation = sample_conversation
11721173

1173-
config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn")
1174+
config = PrependedConversationConfig()
11741175

11751176
state = await manager.initialize_context_async(
11761177
context=context,
@@ -1314,7 +1315,7 @@ async def test_message_normalizer_default_uses_conversation_context_normalizer(
13141315
context.prepended_conversation = sample_conversation
13151316
context.next_message = None
13161317

1317-
config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn")
1318+
config = PrependedConversationConfig()
13181319

13191320
await manager.initialize_context_async(
13201321
context=context,
@@ -1348,7 +1349,6 @@ async def test_message_normalizer_custom_normalizer_is_used(
13481349
context.next_message = None
13491350

13501351
config = PrependedConversationConfig(
1351-
non_chat_target_behavior="normalize_first_turn",
13521352
message_normalizer=mock_normalizer,
13531353
)
13541354

@@ -1372,7 +1372,8 @@ async def test_message_normalizer_custom_normalizer_is_used(
13721372

13731373
def test_default_factory_creates_raise_behavior(self) -> None:
13741374
"""Test that PrependedConversationConfig.default() creates raise behavior."""
1375-
config = PrependedConversationConfig.default()
1375+
with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.default\\(\\) is deprecated"):
1376+
config = PrependedConversationConfig.default()
13761377

13771378
assert config.non_chat_target_behavior == "raise"
13781379
assert config.message_normalizer is None
@@ -1383,7 +1384,10 @@ def test_default_factory_creates_raise_behavior(self) -> None:
13831384

13841385
def test_for_non_chat_target_factory_creates_normalize_behavior(self) -> None:
13851386
"""Test that for_non_chat_target() creates normalize_first_turn behavior."""
1386-
config = PrependedConversationConfig.for_non_chat_target()
1387+
with pytest.warns(
1388+
DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"
1389+
):
1390+
config = PrependedConversationConfig.for_non_chat_target()
13871391

13881392
assert config.non_chat_target_behavior == "normalize_first_turn"
13891393

@@ -1392,14 +1396,20 @@ def test_for_non_chat_target_with_custom_normalizer(self) -> None:
13921396
from pyrit.message_normalizer import MessageStringNormalizer
13931397

13941398
mock_normalizer = MagicMock(spec=MessageStringNormalizer)
1395-
config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer)
1399+
with pytest.warns(
1400+
DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"
1401+
):
1402+
config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer)
13961403

13971404
assert config.message_normalizer == mock_normalizer
13981405
assert config.non_chat_target_behavior == "normalize_first_turn"
13991406

14001407
def test_for_non_chat_target_with_custom_roles(self) -> None:
14011408
"""Test that for_non_chat_target() accepts custom apply_converters_to_roles."""
1402-
config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"])
1409+
with pytest.warns(
1410+
DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"
1411+
):
1412+
config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"])
14031413

14041414
assert config.apply_converters_to_roles == ["user"]
14051415
assert config.non_chat_target_behavior == "normalize_first_turn"
@@ -1421,7 +1431,8 @@ async def test_chat_target_ignores_non_chat_target_behavior(
14211431
context.prepended_conversation = sample_conversation
14221432

14231433
# Even with raise behavior, chat targets should work
1424-
config = PrependedConversationConfig(non_chat_target_behavior="raise")
1434+
with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"):
1435+
config = PrependedConversationConfig(non_chat_target_behavior="raise")
14251436

14261437
state = await manager.initialize_context_async(
14271438
context=context,

tests/unit/executor/attack/component/test_prepended_conversation_config.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import get_args
55
from unittest.mock import MagicMock
66

7+
import pytest
8+
79
from pyrit.executor.attack.component.prepended_conversation_config import PrependedConversationConfig
810
from pyrit.message_normalizer import ConversationContextNormalizer
911
from pyrit.models import ChatMessageRole
@@ -21,7 +23,7 @@ def test_default_init_message_normalizer_is_none():
2123

2224
def test_default_init_non_chat_target_behavior():
2325
config = PrependedConversationConfig()
24-
assert config.non_chat_target_behavior == "normalize_first_turn"
26+
assert config.non_chat_target_behavior is None
2527

2628

2729
def test_get_message_normalizer_returns_default_when_none():
@@ -37,33 +39,74 @@ def test_get_message_normalizer_returns_custom():
3739

3840

3941
def test_default_class_method():
40-
config = PrependedConversationConfig.default()
42+
with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.default\\(\\) is deprecated"):
43+
config = PrependedConversationConfig.default()
4144
assert config.apply_converters_to_roles == list(get_args(ChatMessageRole))
4245
assert config.message_normalizer is None
4346
assert config.non_chat_target_behavior == "raise"
4447

4548

49+
def test_explicit_raise_emits_deprecation_warning():
50+
with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"):
51+
config = PrependedConversationConfig(non_chat_target_behavior="raise")
52+
assert config.non_chat_target_behavior == "raise"
53+
54+
55+
def test_explicit_normalize_first_turn_emits_deprecation_warning():
56+
with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"):
57+
config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn")
58+
assert config.non_chat_target_behavior == "normalize_first_turn"
59+
60+
61+
def test_default_init_does_not_emit_deprecation_warning(recwarn):
62+
PrependedConversationConfig()
63+
deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
64+
assert deprecation_warnings == []
65+
66+
67+
def test_explicit_none_does_not_emit_deprecation_warning(recwarn):
68+
PrependedConversationConfig(non_chat_target_behavior=None)
69+
deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
70+
assert deprecation_warnings == []
71+
72+
73+
def test_default_factory_emits_single_deprecation_warning(recwarn):
74+
PrependedConversationConfig.default()
75+
deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
76+
assert len(deprecation_warnings) == 1
77+
78+
79+
def test_for_non_chat_target_emits_single_deprecation_warning(recwarn):
80+
PrependedConversationConfig.for_non_chat_target()
81+
deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
82+
assert len(deprecation_warnings) == 1
83+
84+
4685
def test_for_non_chat_target_defaults():
47-
config = PrependedConversationConfig.for_non_chat_target()
86+
with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"):
87+
config = PrependedConversationConfig.for_non_chat_target()
4888
assert config.apply_converters_to_roles == list(get_args(ChatMessageRole))
4989
assert config.message_normalizer is None
5090
assert config.non_chat_target_behavior == "normalize_first_turn"
5191

5292

5393
def test_for_non_chat_target_with_custom_normalizer():
5494
mock_normalizer = MagicMock()
55-
config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer)
95+
with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"):
96+
config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer)
5697
assert config.message_normalizer is mock_normalizer
5798
assert config.non_chat_target_behavior == "normalize_first_turn"
5899

59100

60101
def test_for_non_chat_target_with_specific_roles():
61-
config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"])
102+
with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"):
103+
config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"])
62104
assert config.apply_converters_to_roles == ["user"]
63105

64106

65107
def test_default_vs_init_differ_in_behavior():
66-
default_config = PrependedConversationConfig.default()
108+
with pytest.warns(DeprecationWarning):
109+
default_config = PrependedConversationConfig.default()
67110
init_config = PrependedConversationConfig()
68111
assert default_config.non_chat_target_behavior == "raise"
69-
assert init_config.non_chat_target_behavior == "normalize_first_turn"
112+
assert init_config.non_chat_target_behavior is None

0 commit comments

Comments
 (0)