99
1010from __future__ import annotations
1111
12- import abc
1312import logging
1413import re
1514import uuid
16- from dataclasses import dataclass , field
1715from 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
2118from jinja2 import StrictUndefined , Undefined
2219from 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
2724if 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-
3328logger = logging .getLogger (__name__ )
3429
3530# TypeVar for generic return type in class methods
3631T = 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+
3959class 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 )
0 commit comments