Skip to content

Commit ed8f6cf

Browse files
rlundeen2Copilot
andauthored
MAINT: Convert leaf model types to Pydanticv2 (#1769)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e965f36 commit ed8f6cf

14 files changed

Lines changed: 157 additions & 113 deletions

pyrit/memory/memory_models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,7 @@ def __init__(self, *, entry: AttackResult) -> None:
824824

825825
# Retry events
826826
self.retry_events_json = (
827-
json.dumps([evt.to_dict() for evt in entry.retry_events]) if entry.retry_events else None
827+
json.dumps([evt.model_dump(mode="json") for evt in entry.retry_events]) if entry.retry_events else None
828828
)
829829
self.total_retries = entry.total_retries
830830

@@ -923,7 +923,7 @@ def get_attack_result(self) -> AttackResult:
923923
if self.retry_events_json:
924924
from pyrit.models.retry_event import RetryEvent
925925

926-
retry_events = [RetryEvent.from_dict(evt_dict) for evt_dict in json.loads(self.retry_events_json)]
926+
retry_events = [RetryEvent.model_validate(evt_dict) for evt_dict in json.loads(self.retry_events_json)]
927927

928928
return AttackResult(
929929
conversation_id=self.conversation_id,

pyrit/models/attack_result.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,15 @@ def to_dict(self) -> dict[str, Any]:
250250
"outcome_reason": self.outcome_reason,
251251
"timestamp": self.timestamp.isoformat(),
252252
"related_conversations": sorted(
253-
[ref.to_dict() for ref in self.related_conversations],
253+
[ref.model_dump(mode="json") for ref in self.related_conversations],
254254
key=lambda r: r["conversation_id"],
255255
),
256256
"metadata": self.metadata,
257257
"labels": self.labels,
258258
"error_message": self.error_message,
259259
"error_type": self.error_type,
260260
"error_traceback": self.error_traceback,
261-
"retry_events": [e.to_dict() for e in self.retry_events],
261+
"retry_events": [e.model_dump(mode="json") for e in self.retry_events],
262262
"total_retries": self.total_retries,
263263
}
264264

@@ -291,13 +291,15 @@ def from_dict(cls, data: dict[str, Any]) -> AttackResult:
291291
timestamp=(
292292
datetime.fromisoformat(data["timestamp"]) if data.get("timestamp") else datetime.now(timezone.utc)
293293
),
294-
related_conversations={ConversationReference.from_dict(r) for r in data.get("related_conversations", [])},
294+
related_conversations={
295+
ConversationReference.model_validate(r) for r in data.get("related_conversations", [])
296+
},
295297
metadata=data.get("metadata", {}),
296298
labels=data.get("labels", {}),
297299
error_message=data.get("error_message"),
298300
error_type=data.get("error_type"),
299301
error_traceback=data.get("error_traceback"),
300-
retry_events=[RetryEvent.from_dict(e) for e in data.get("retry_events", [])],
302+
retry_events=[RetryEvent.model_validate(e) for e in data.get("retry_events", [])],
301303
total_retries=data.get("total_retries", 0),
302304
)
303305

pyrit/models/conversation_reference.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33

44
from __future__ import annotations
55

6-
from dataclasses import dataclass
76
from enum import Enum
87
from typing import Optional
98

9+
from pydantic import BaseModel, ConfigDict
10+
11+
from pyrit.common.deprecation import print_deprecation_message
12+
1013

1114
class ConversationType(Enum):
1215
"""Types of conversations that can be associated with an attack."""
@@ -17,15 +20,15 @@ class ConversationType(Enum):
1720
CONVERTER = "converter"
1821

1922

20-
@dataclass(frozen=True)
21-
class ConversationReference:
23+
class ConversationReference(BaseModel):
2224
"""Immutable reference to a conversation that played a role in the attack."""
2325

26+
model_config = ConfigDict(frozen=True)
27+
2428
conversation_id: str
2529
conversation_type: ConversationType
2630
description: Optional[str] = None
2731

28-
# Allow use in set / dict
2932
def __hash__(self) -> int:
3033
"""
3134
Return a hash derived from conversation ID.
@@ -36,45 +39,55 @@ def __hash__(self) -> int:
3639
"""
3740
return hash(self.conversation_id)
3841

42+
def __eq__(self, other: object) -> bool:
43+
"""
44+
Compare two references by conversation ID.
45+
46+
Args:
47+
other (object): Other object to compare.
48+
49+
Returns:
50+
bool: True when the other object is a matching ConversationReference.
51+
52+
"""
53+
return isinstance(other, ConversationReference) and self.conversation_id == other.conversation_id
54+
3955
def to_dict(self) -> dict[str, str | None]:
4056
"""
4157
Serialize to a JSON-compatible dictionary.
4258
59+
.. deprecated::
60+
Use :meth:`model_dump` with ``mode="json"`` instead. This method
61+
will be removed in version 0.16.0.
62+
4363
Returns:
4464
dict[str, str | None]: Dictionary with conversation_id, conversation_type, and description.
4565
"""
46-
return {
47-
"conversation_id": self.conversation_id,
48-
"conversation_type": self.conversation_type.value,
49-
"description": self.description,
50-
}
66+
print_deprecation_message(
67+
old_item=ConversationReference.to_dict,
68+
new_item='ConversationReference.model_dump(mode="json")',
69+
removed_in="0.16.0",
70+
)
71+
return self.model_dump(mode="json")
5172

5273
@classmethod
5374
def from_dict(cls, data: dict[str, str | None]) -> ConversationReference:
5475
"""
5576
Reconstruct a ConversationReference from a dictionary.
5677
78+
.. deprecated::
79+
Use :meth:`model_validate` instead. This method will be removed
80+
in version 0.16.0.
81+
5782
Args:
58-
data (dict[str, str | None]): Dictionary as produced by to_dict().
83+
data (dict[str, str | None]): Dictionary as produced by ``model_dump(mode="json")``.
5984
6085
Returns:
6186
ConversationReference: Reconstructed instance.
6287
"""
63-
return cls(
64-
conversation_id=str(data["conversation_id"]),
65-
conversation_type=ConversationType(data["conversation_type"]),
66-
description=data.get("description"),
88+
print_deprecation_message(
89+
old_item=ConversationReference.from_dict,
90+
new_item="ConversationReference.model_validate",
91+
removed_in="0.16.0",
6792
)
68-
69-
def __eq__(self, other: object) -> bool:
70-
"""
71-
Compare two references by conversation ID.
72-
73-
Args:
74-
other (object): Other object to compare.
75-
76-
Returns:
77-
bool: True when the other object is a matching ConversationReference.
78-
79-
"""
80-
return isinstance(other, ConversationReference) and self.conversation_id == other.conversation_id
93+
return cls.model_validate(data)

pyrit/models/conversation_stats.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
from __future__ import annotations
4+
from datetime import datetime
5+
from typing import ClassVar, Optional
56

6-
from dataclasses import dataclass, field
7-
from typing import TYPE_CHECKING, ClassVar, Optional
7+
from pydantic import BaseModel, ConfigDict, Field
88

9-
if TYPE_CHECKING:
10-
from datetime import datetime
119

12-
13-
@dataclass(frozen=True)
14-
class ConversationStats:
10+
class ConversationStats(BaseModel):
1511
"""
1612
Lightweight aggregate statistics for a conversation.
1713
1814
Used to build attack summaries without loading full message pieces.
1915
"""
2016

17+
model_config = ConfigDict(frozen=True)
18+
2119
PREVIEW_MAX_LEN: ClassVar[int] = 100
2220

2321
message_count: int = 0
2422
last_message_preview: Optional[str] = None
25-
labels: dict[str, str] = field(default_factory=dict)
23+
labels: dict[str, str] = Field(default_factory=dict)
2624
created_at: Optional[datetime] = None

pyrit/models/harm_definition.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,18 @@
99

1010
import logging
1111
import re
12-
from dataclasses import dataclass, field
1312
from pathlib import Path
1413
from typing import Optional, Union
1514

1615
import yaml
16+
from pydantic import BaseModel, Field
1717

1818
from pyrit.common.path import HARM_DEFINITION_PATH
1919

2020
logger = logging.getLogger(__name__)
2121

2222

23-
@dataclass
24-
class ScaleDescription:
23+
class ScaleDescription(BaseModel):
2524
"""
2625
A single scale description entry from a harm definition.
2726
@@ -35,8 +34,7 @@ class ScaleDescription:
3534
description: str
3635

3736

38-
@dataclass
39-
class HarmDefinition:
37+
class HarmDefinition(BaseModel):
4038
"""
4139
A harm definition loaded from a YAML file.
4240
@@ -54,8 +52,8 @@ class HarmDefinition:
5452

5553
version: str
5654
category: str
57-
scale_descriptions: list[ScaleDescription] = field(default_factory=list)
58-
source_path: Optional[str] = field(default=None, kw_only=True)
55+
scale_descriptions: list[ScaleDescription] = Field(default_factory=list)
56+
source_path: Optional[str] = None
5957

6058
def get_scale_description(self, score_value: str) -> Optional[str]:
6159
"""
@@ -92,7 +90,6 @@ def validate_category(category: str, *, check_exists: bool = False) -> bool:
9290
False otherwise.
9391
9492
"""
95-
# Check if category matches pattern: only lowercase letters and underscores
9693
if not re.match(r"^[a-z_]+$", category):
9794
return False
9895

@@ -127,7 +124,6 @@ def from_yaml(cls, harm_definition_path: Union[str, Path]) -> "HarmDefinition":
127124
"""
128125
path = Path(harm_definition_path)
129126

130-
# If it's just a filename (no directory separators), look in the standard directory
131127
resolved_path = HARM_DEFINITION_PATH / path if path.parent == Path(".") else path
132128

133129
if not resolved_path.exists():
@@ -145,15 +141,13 @@ def from_yaml(cls, harm_definition_path: Union[str, Path]) -> "HarmDefinition":
145141
if not isinstance(data, dict):
146142
raise ValueError(f"Harm definition file {resolved_path} must contain a YAML mapping/dictionary.")
147143

148-
# Validate required fields
149144
if "version" not in data:
150145
raise ValueError(f"Harm definition file {resolved_path} is missing required 'version' field.")
151146
if "category" not in data:
152147
raise ValueError(f"Harm definition file {resolved_path} is missing required 'category' field.")
153148
if "scale_descriptions" not in data:
154149
raise ValueError(f"Harm definition file {resolved_path} is missing required 'scale_descriptions' field.")
155150

156-
# Parse scale descriptions
157151
scale_descriptions = []
158152
for item in data["scale_descriptions"]:
159153
if not isinstance(item, dict) or "score_value" not in item or "description" not in item:

pyrit/models/json_response_config.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
from __future__ import annotations
55

66
import json
7-
from dataclasses import dataclass
87
from typing import Any, Optional
98

9+
from pydantic import BaseModel, ConfigDict
10+
1011
# Would prefer StrEnum, but.... Python 3.10
1112
_METADATAKEYS = {
1213
"RESPONSE_FORMAT": "response_format",
@@ -16,8 +17,7 @@
1617
}
1718

1819

19-
@dataclass
20-
class _JsonResponseConfig:
20+
class _JsonResponseConfig(BaseModel):
2121
"""
2222
Configuration for JSON responses (with OpenAI).
2323
@@ -27,8 +27,10 @@ class _JsonResponseConfig:
2727
https://platform.openai.com/docs/api-reference/responses/create#responses_create-text
2828
"""
2929

30+
model_config = ConfigDict(extra="forbid")
31+
3032
enabled: bool = False
31-
schema: Optional[dict[str, Any]] = None
33+
json_schema: Optional[dict[str, Any]] = None
3234
schema_name: str = "CustomSchema"
3335
strict: bool = True
3436

@@ -53,7 +55,7 @@ def from_metadata(cls, *, metadata: Optional[dict[str, Any]]) -> _JsonResponseCo
5355

5456
return cls(
5557
enabled=True,
56-
schema=schema,
58+
json_schema=schema,
5759
schema_name=metadata.get(_METADATAKEYS["JSON_SCHEMA_NAME"], "CustomSchema"),
5860
strict=metadata.get(_METADATAKEYS["JSON_SCHEMA_STRICT"], True),
5961
)

0 commit comments

Comments
 (0)