Skip to content

Commit 2f42aad

Browse files
rlundeen2Copilot
andauthored
MAINT: Migrating Seed classes to Pydantic (#1898)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e6f1a32 commit 2f42aad

22 files changed

Lines changed: 1159 additions & 721 deletions

.github/instructions/models.instructions.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,30 @@ applyTo: "pyrit/models/**"
66

77
## Import Boundary
88

9-
`pyrit.models` is the canonical data layer. Files in `pyrit/models/` may
10-
import only from:
9+
PyRIT enforces a two-layer rule for its foundational packages. `pyrit.common`
10+
is the foundation layer and `pyrit.models` is the canonical data layer that sits
11+
directly on top of it.
12+
13+
**Forward (models).** Files in `pyrit/models/` may import only from:
1114

1215
- the standard library
1316
- `pydantic`
14-
- `pyrit.common.deprecation`
17+
- any `pyrit.common.*` submodule (the whole prefix)
1518
- other `pyrit.models.*` submodules
1619

17-
If a helper needs another `pyrit.*` package, it does not belong on a model —
18-
put it in that package as a free function or static helper.
20+
If a helper needs another `pyrit.*` package (e.g. `pyrit.memory`,
21+
`pyrit.score`), it does not belong on a model — put it in that package as a free
22+
function or static helper.
23+
24+
**Reverse guard (common).** Files in `pyrit/common/` may import only from the
25+
standard library, third-party libraries, and other `pyrit.common.*` submodules.
26+
`pyrit.common` may never import any other `pyrit.*` package — this is what keeps
27+
it a true foundation and prevents an import cycle with `pyrit.models`. If a
28+
`pyrit.common` helper needs `pyrit.models` (or anything higher), it belongs in
29+
that higher layer, not in `common`.
1930

20-
The CI test `tests/unit/models/test_import_boundary.py` enforces this using an
21-
allowlist of known transitional violations, each tagged with the phase that
22-
removes it. The list must shrink monotonically: removing an import from source
23-
without also removing its allowlist entry fails the test, and adding a new
24-
unlisted import also fails the test.
31+
The CI test `tests/unit/models/test_import_boundary.py` enforces both directions
32+
using allowlists of known transitional violations, each tagged with the phase
33+
that removes it. The lists must shrink monotonically: removing an import from
34+
source without also removing its allowlist entry fails the test, and adding a
35+
new unlisted import also fails the test.

pyrit/executor/attack/core/attack_parameters.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,21 @@ async def from_seed_group_async(
101101
An instance of this AttackParameters type.
102102
103103
Raises:
104-
ValueError: If seed_group has no objective or if overrides contain invalid fields.
105-
ValueError: If seed_group has simulated conversation but adversarial_chat/scorer not provided.
104+
TypeError: If ``seed_group`` is not a ``SeedAttackGroup``.
105+
ValueError: If overrides contain invalid fields, or if seed_group has simulated
106+
conversation but adversarial_chat/scorer not provided.
106107
"""
107108
# Import here to avoid circular imports
108109
from pyrit.executor.attack.multi_turn.simulated_conversation import (
109110
generate_simulated_conversation_async,
110111
)
111112

113+
if not isinstance(seed_group, SeedAttackGroup):
114+
raise TypeError(
115+
f"seed_group must be a SeedAttackGroup, got {type(seed_group).__name__}. "
116+
"Plain SeedGroup does not enforce the 'exactly one objective' invariant required for an attack."
117+
)
118+
112119
# Get valid field names for this params type
113120
valid_fields = {f.name for f in dataclasses.fields(cls)}
114121

@@ -119,12 +126,8 @@ async def from_seed_group_async(
119126
f"{cls.__name__} does not accept parameters: {invalid_fields}. Accepted parameters: {valid_fields}"
120127
)
121128

122-
# Validate seed_group state before extracting parameters
123-
seed_group.validate()
124-
125-
# SeedAttackGroup validates in __init__ that objective is set
126-
if seed_group.objective is None:
127-
raise ValueError("seed_group.objective is not initialized")
129+
# SeedAttackGroup's Pydantic validator guarantees exactly one objective is present.
130+
assert seed_group.objective is not None
128131

129132
# Build params dict, only including fields this class accepts
130133
params: dict[str, Any] = {}

pyrit/models/seeds/__init__.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,24 @@
2727
SeedSimulatedConversation,
2828
SimulatedTargetSystemPromptPaths,
2929
)
30+
from pyrit.models.seeds.yaml_seed_loader import (
31+
load_seed_dataset_from_yaml,
32+
load_seed_from_yaml,
33+
load_seed_prompt_from_yaml_with_required_parameters,
34+
)
3035

3136
__all__ = [
37+
"load_seed_dataset_from_yaml",
38+
"load_seed_from_yaml",
39+
"load_seed_prompt_from_yaml_with_required_parameters",
40+
"NextMessageSystemPromptPaths",
3241
"Seed",
33-
"SeedPrompt",
34-
"SeedObjective",
35-
"SeedGroup",
3642
"SeedAttackGroup",
3743
"SeedAttackTechniqueGroup",
44+
"SeedDataset",
45+
"SeedGroup",
46+
"SeedObjective",
47+
"SeedPrompt",
3848
"SeedSimulatedConversation",
3949
"SimulatedTargetSystemPromptPaths",
40-
"NextMessageSystemPromptPaths",
41-
"SeedDataset",
4250
]

pyrit/models/seeds/seed.py

Lines changed: 59 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,53 @@
99

1010
from __future__ import annotations
1111

12-
import abc
1312
import logging
1413
import re
1514
import uuid
16-
from dataclasses import dataclass, field
1715
from datetime import datetime, timezone
18-
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
16+
from typing import TYPE_CHECKING, Annotated, Any, TypeVar
1917

20-
import yaml
2118
from jinja2 import StrictUndefined, Undefined
2219
from jinja2.sandbox import SandboxedEnvironment
20+
from pydantic import AwareDatetime, BaseModel, BeforeValidator, ConfigDict, Field
2321

24-
from pyrit.common.utils import verify_and_resolve_path
25-
from pyrit.common.yaml_loadable import YamlLoadable
22+
from pyrit.models.literals import PromptDataType # noqa: TC001 (runtime-required by Pydantic field annotations)
2623

2724
if TYPE_CHECKING:
28-
from collections.abc import Iterator, Sequence
25+
from collections.abc import Iterator
2926
from pathlib import Path
3027

31-
from pyrit.models.literals import PromptDataType
32-
3328
logger = logging.getLogger(__name__)
3429

3530
# TypeVar for generic return type in class methods
3631
T = TypeVar("T", bound="Seed")
3732

3833

34+
def _ensure_aware_utc(value: Any) -> Any:
35+
"""
36+
Coerce naive datetimes (and bare date strings) to UTC so AwareDatetime accepts them.
37+
38+
Args:
39+
value: The raw value provided for a datetime field (string, datetime, or anything else).
40+
41+
Returns:
42+
Any: A timezone-aware datetime when the input was naive or a parseable date string;
43+
otherwise the value unchanged for Pydantic to validate.
44+
"""
45+
if isinstance(value, str):
46+
try:
47+
value = datetime.fromisoformat(value)
48+
except ValueError:
49+
return value
50+
if isinstance(value, datetime) and value.tzinfo is None:
51+
return value.replace(tzinfo=timezone.utc)
52+
return value
53+
54+
55+
# Timezone-aware datetime that interprets naive inputs as UTC instead of rejecting them.
56+
AwareDatetimeUTC = Annotated[AwareDatetime, BeforeValidator(_ensure_aware_utc)]
57+
58+
3959
class PartialUndefined(Undefined):
4060
"""Jinja undefined value that preserves unresolved placeholders as text."""
4161

@@ -81,54 +101,55 @@ def __bool__(self) -> bool:
81101
return True # Ensures it doesn't evaluate to False
82102

83103

84-
@dataclass
85-
class Seed(YamlLoadable):
104+
class Seed(BaseModel):
86105
"""Represents seed data with various attributes and metadata."""
87106

107+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
108+
88109
# The actual prompt value, which can be a string or a file path
89110
value: str
90111

91112
# SHA256 hash of the value, used for deduplication
92-
value_sha256: Optional[str] = None
113+
value_sha256: str | None = None
93114

94115
# Unique identifier for the prompt
95-
id: Optional[uuid.UUID] = field(default_factory=lambda: uuid.uuid4())
116+
id: uuid.UUID | None = Field(default_factory=uuid.uuid4)
96117

97118
# Name of the prompt
98-
name: Optional[str] = None
119+
name: str | None = None
99120

100121
# Name of the dataset this prompt belongs to
101-
dataset_name: Optional[str] = None
122+
dataset_name: str | None = None
102123

103124
# Categories of harm associated with this prompt
104-
harm_categories: Optional[Sequence[str]] = field(default_factory=list)
125+
harm_categories: list[str] | None = Field(default_factory=list)
105126

106127
# Description of the prompt
107-
description: Optional[str] = None
128+
description: str | None = None
108129

109130
# Authors of the prompt
110-
authors: Optional[Sequence[str]] = field(default_factory=list)
131+
authors: list[str] | None = Field(default_factory=list)
111132

112133
# Groups affiliated with the prompt
113-
groups: Optional[Sequence[str]] = field(default_factory=list)
134+
groups: list[str] | None = Field(default_factory=list)
114135

115136
# Source of the prompt
116-
source: Optional[str] = None
137+
source: str | None = None
117138

118139
# Date when the prompt was added to the dataset
119-
date_added: Optional[datetime] = field(default_factory=lambda: datetime.now(tz=timezone.utc))
140+
date_added: AwareDatetimeUTC | None = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
120141

121142
# User who added the prompt to the dataset
122-
added_by: Optional[str] = None
143+
added_by: str | None = None
123144

124145
# Arbitrary metadata that can be attached to the prompt
125-
metadata: Optional[dict[str, Union[str, int]]] = field(default_factory=dict)
146+
metadata: dict[str, Any] | None = Field(default_factory=dict)
126147

127148
# Unique identifier for the prompt group
128-
prompt_group_id: Optional[uuid.UUID] = None
149+
prompt_group_id: uuid.UUID | None = None
129150

130151
# Alias for the prompt group
131-
prompt_group_alias: Optional[str] = None
152+
prompt_group_alias: str | None = None
132153

133154
# Whether this seed represents a general attack technique (not tied to a specific objective)
134155
is_general_technique: bool = False
@@ -138,15 +159,10 @@ class Seed(YamlLoadable):
138159
# to prevent template injection. Trusted sources (YAML files) set this to True automatically.
139160
is_jinja_template: bool = False
140161

141-
@property
142-
def data_type(self) -> PromptDataType:
143-
"""
144-
Return the data type for this seed.
145-
146-
Base implementation returns 'text'. SeedPrompt overrides this
147-
to support multiple data types (image_path, audio_path, etc.).
148-
"""
149-
return "text"
162+
# The type of data this seed represents (e.g., text, image_path, audio_path, video_path).
163+
# SeedPrompt overrides the default to None and infers it from the value; other seed types
164+
# narrow it to Literal["text"].
165+
data_type: PromptDataType = "text"
150166

151167
def render_template_value(self, **kwargs: Any) -> str:
152168
"""
@@ -247,46 +263,24 @@ def escape_for_jinja(value: str) -> str:
247263
return f"{{% raw %}}{value}{{% endraw %}}"
248264

249265
@classmethod
250-
def from_yaml_file(cls: type[T], file: Union[str, Path]) -> T:
266+
def from_yaml_file(cls: type[T], file: str | Path) -> T:
251267
"""
252268
Create a new Seed from a YAML file, marking it as a trusted Jinja2 template.
253269
270+
Thin shim that delegates to ``load_seed_from_yaml`` in the ``yaml_seed_loader`` module;
271+
file I/O and the ``is_jinja_template`` trust marker live in the loader module.
272+
254273
Args:
255274
file: The input file path.
256275
257276
Returns:
258277
A new Seed of the specific subclass type.
259278
260279
Raises:
261-
ValueError: If the YAML file is invalid.
262-
"""
263-
file = verify_and_resolve_path(file)
264-
265-
try:
266-
yaml_data = yaml.safe_load(file.read_text("utf-8"))
267-
except yaml.YAMLError as exc:
268-
raise ValueError(f"Invalid YAML file '{file}': {exc}") from exc
269-
270-
yaml_data["is_jinja_template"] = True
271-
return cls(**yaml_data)
272-
273-
@classmethod
274-
@abc.abstractmethod
275-
def from_yaml_with_required_parameters(
276-
cls,
277-
template_path: Union[str, Path],
278-
required_parameters: list[str],
279-
error_message: Optional[str] = None,
280-
) -> Seed:
280+
FileNotFoundError: If the path does not resolve to an existing file.
281+
ValueError: If the YAML file is invalid or empty.
281282
"""
282-
Load a Seed from a YAML file and validate that it contains specific parameters.
283-
284-
Args:
285-
template_path: Path to the YAML file containing the template.
286-
required_parameters: List of parameter names that must exist in the template.
287-
error_message: Custom error message if validation fails. If None, a default message is used.
283+
# Deferred import: yaml_seed_loader imports Seed, so importing it at module top would cycle.
284+
from pyrit.models.seeds.yaml_seed_loader import load_seed_from_yaml
288285

289-
Returns:
290-
Seed: The loaded and validated seed of the specific subclass type.
291-
292-
"""
286+
return load_seed_from_yaml(file, cls=cls)

pyrit/models/seeds/seed_attack_group.py

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99

1010
from __future__ import annotations
1111

12-
from typing import TYPE_CHECKING, Any, Union
12+
from typing import TYPE_CHECKING
1313

1414
from pyrit.models.seeds.seed_group import SeedGroup
1515
from pyrit.models.seeds.seed_objective import SeedObjective
1616

1717
if TYPE_CHECKING:
1818
from collections.abc import Sequence
1919

20-
from pyrit.models.seeds.seed import Seed
2120
from pyrit.models.seeds.seed_attack_technique_group import SeedAttackTechniqueGroup
2221

2322

@@ -32,25 +31,7 @@ class SeedAttackGroup(SeedGroup):
3231
next_message, etc.) is inherited from SeedGroup.
3332
"""
3433

35-
def __init__(
36-
self,
37-
*,
38-
seeds: Sequence[Union[Seed, dict[str, Any]]],
39-
) -> None:
40-
"""
41-
Initialize a SeedAttackGroup.
42-
43-
Args:
44-
seeds: Sequence of seeds. Must include exactly one SeedObjective.
45-
46-
Raises:
47-
ValueError: If seeds is empty.
48-
ValueError: If exactly one objective is not provided.
49-
50-
"""
51-
super().__init__(seeds=seeds)
52-
53-
def validate(self) -> None:
34+
def _check_invariants(self) -> None:
5435
"""
5536
Validate the seed attack group state.
5637
@@ -60,7 +41,7 @@ def validate(self) -> None:
6041
ValueError: If validation fails.
6142
6243
"""
63-
super().validate()
44+
super()._check_invariants()
6445
self._enforce_exactly_one_objective()
6546

6647
def _enforce_exactly_one_objective(self) -> None:

0 commit comments

Comments
 (0)