Skip to content

Commit 01f4abc

Browse files
varunj-msftCopilot
andauthored
FIX: Preserve DatasetConfiguration subclass when backend overrides dataset_names (#1911)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 309b7a5 commit 01f4abc

2 files changed

Lines changed: 214 additions & 38 deletions

File tree

pyrit/backend/services/scenario_run_service.py

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,13 @@ def _build_init_kwargs(
267267
268268
Resolves strategies and dataset configuration from the request.
269269
270+
Dataset configuration is built so that the scenario's default
271+
``DatasetConfiguration`` *subclass* (e.g. ``EncodingDatasetConfiguration``)
272+
is preserved when the caller overrides ``dataset_names`` or
273+
``max_dataset_size``. Subclasses commonly override
274+
``get_all_seed_attack_groups()`` or ``_load_seed_groups_for_dataset()``
275+
to shape seeds into scenario-appropriate ``SeedAttackGroup`` objects.
276+
270277
Args:
271278
request: The run request.
272279
scenario_class: The resolved scenario class.
@@ -276,7 +283,10 @@ def _build_init_kwargs(
276283
Dict of kwargs to pass to scenario.initialize_async.
277284
278285
Raises:
279-
ValueError: If a strategy name is invalid for the scenario.
286+
ValueError: If a strategy name is invalid for the scenario, or the
287+
scenario class cannot be instantiated with no arguments when
288+
introspection is required to resolve strategies or dataset
289+
configuration.
280290
"""
281291
init_kwargs: dict[str, Any] = {
282292
"objective_target": objective_target,
@@ -287,47 +297,78 @@ def _build_init_kwargs(
287297
if request.labels:
288298
init_kwargs["memory_labels"] = request.labels
289299

290-
# Resolve strategies and default dataset config from a temporary instance
291-
# of the scenario. The downstream _initialize_scenario_async builds its
292-
# own instance (so that scenario_result_id can be passed), so this is a
293-
# cheap throwaway used only for introspection.
294-
needs_introspection = bool(request.strategies) or (
295-
request.max_dataset_size is not None and not request.dataset_names
300+
# Resolve strategies and dataset config from a temporary instance of the
301+
# scenario. The downstream _initialize_scenario_async builds its own
302+
# instance (so scenario_result_id can be passed), so this is a cheap
303+
# throwaway used only for introspection. Introspection is required
304+
# whenever the caller wants to override strategies, dataset names, or
305+
# the sample cap, because each of those needs the scenario's own
306+
# strategy enum or dataset-config subclass to be resolved correctly.
307+
needs_introspection = (
308+
bool(request.strategies) or bool(request.dataset_names) or request.max_dataset_size is not None
296309
)
297-
if needs_introspection:
298-
try:
299-
introspection_instance = scenario_class() # type: ignore[ty:missing-argument]
300-
except Exception as exc:
301-
raise ValueError(
302-
f"Cannot resolve runtime configuration for scenario '{request.scenario_name}': "
303-
f"scenario class is not instantiable without arguments ({exc})."
304-
) from exc
305-
306-
if request.strategies:
307-
strategy_class = introspection_instance._strategy_class
308-
strategy_enums = []
309-
for name in request.strategies:
310-
try:
311-
strategy_enums.append(strategy_class(name))
312-
except ValueError:
313-
available_strategies = [s.value for s in strategy_class]
314-
raise ValueError(
315-
f"Strategy '{name}' not found for scenario '{request.scenario_name}'. "
316-
f"Available: {', '.join(available_strategies)}"
317-
) from None
318-
init_kwargs["scenario_strategies"] = strategy_enums
319-
320-
if request.max_dataset_size is not None and not request.dataset_names:
321-
default_config = introspection_instance._default_dataset_config
310+
if not needs_introspection:
311+
return init_kwargs
312+
313+
try:
314+
introspection_instance = scenario_class() # type: ignore[ty:missing-argument]
315+
except Exception as exc:
316+
raise ValueError(
317+
f"Cannot resolve runtime configuration for scenario '{request.scenario_name}': "
318+
f"scenario class is not instantiable without arguments ({exc})."
319+
) from exc
320+
321+
if request.strategies:
322+
strategy_class = introspection_instance._strategy_class
323+
strategy_enums = []
324+
for name in request.strategies:
325+
try:
326+
strategy_enums.append(strategy_class(name))
327+
except ValueError:
328+
available_strategies = [s.value for s in strategy_class]
329+
raise ValueError(
330+
f"Strategy '{name}' not found for scenario '{request.scenario_name}'. "
331+
f"Available: {', '.join(available_strategies)}"
332+
) from None
333+
init_kwargs["scenario_strategies"] = strategy_enums
334+
335+
if request.dataset_names or request.max_dataset_size is not None:
336+
default_config = introspection_instance._default_dataset_config
337+
338+
if request.dataset_names:
339+
# Construct a fresh instance of the scenario's own dataset-config
340+
# class so subclass-specific behavior is preserved.
341+
default_config_class = type(default_config)
342+
try:
343+
init_kwargs["dataset_config"] = default_config_class(
344+
dataset_names=request.dataset_names,
345+
max_dataset_size=request.max_dataset_size,
346+
)
347+
except TypeError as exc:
348+
# The subclass __init__ takes extra required kwargs we cannot
349+
# supply from a backend request. Fall back to the base
350+
# DatasetConfiguration so the run can still proceed; downstream
351+
# scenarios that strictly require the subclass should either
352+
# define a no-extra-required-args constructor or surface the
353+
# incompatibility through their own initialize_async validation.
354+
logger.warning(
355+
"Cannot construct %s(dataset_names=..., max_dataset_size=...) (%s). "
356+
"Falling back to a generic DatasetConfiguration; scenario-specific "
357+
"dataset-config behavior may be lost.",
358+
default_config_class.__name__,
359+
exc,
360+
)
361+
init_kwargs["dataset_config"] = DatasetConfiguration(
362+
dataset_names=request.dataset_names,
363+
max_dataset_size=request.max_dataset_size,
364+
)
365+
elif request.max_dataset_size is not None:
366+
# Reuse the scenario's default dataset config (preserves subtype +
367+
# the scenario's own default dataset names) and override only the
368+
# sample cap. Safe because the introspection instance is throwaway.
322369
default_config.max_dataset_size = request.max_dataset_size
323370
init_kwargs["dataset_config"] = default_config
324371

325-
if request.dataset_names:
326-
init_kwargs["dataset_config"] = DatasetConfiguration(
327-
dataset_names=request.dataset_names,
328-
max_dataset_size=request.max_dataset_size,
329-
)
330-
331372
return init_kwargs
332373

333374
async def _initialize_scenario_async(

tests/unit/backend/test_scenario_run_service.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from datetime import datetime, timezone
9+
from typing import Any
910
from unittest.mock import AsyncMock, MagicMock, patch
1011

1112
import pytest
@@ -20,6 +21,7 @@
2021
ScenarioRunService,
2122
)
2223
from pyrit.models import AttackOutcome
24+
from pyrit.scenario.core import DatasetConfiguration
2325

2426
_REGISTRY_PATCH_BASE = "pyrit.registry"
2527
_MEMORY_PATCH = "pyrit.memory.CentralMemory.get_memory_instance"
@@ -279,6 +281,139 @@ async def test_start_run_max_dataset_size_uses_default_config(self, mock_all_reg
279281
init_call = scenario_instance.initialize_async.await_args
280282
assert init_call.kwargs["dataset_config"] is default_config
281283

284+
async def test_start_run_dataset_names_preserves_subclass_config_type(self, mock_all_registries) -> None:
285+
"""``dataset_names`` rebuilds the config using the scenario's own DatasetConfiguration subclass.
286+
287+
Regression: passing ``dataset_names`` via the backend used to construct
288+
a plain ``DatasetConfiguration``, silently losing subclass behavior
289+
(e.g. ``EncodingDatasetConfiguration``'s objective shaping).
290+
"""
291+
292+
# Create a marker subclass so we can verify type preservation without
293+
# depending on any concrete scenario implementation.
294+
class _MarkerDatasetConfiguration(DatasetConfiguration):
295+
pass
296+
297+
default_config = _MarkerDatasetConfiguration(dataset_names=["original"], max_dataset_size=100)
298+
scenario_instance = mock_all_registries["scenario_instance"]
299+
scenario_instance._default_dataset_config = default_config
300+
301+
service = ScenarioRunService()
302+
await service.start_run_async(request=_make_request(dataset_names=["custom_a", "custom_b"], max_dataset_size=3))
303+
304+
init_call = scenario_instance.initialize_async.await_args
305+
built_config = init_call.kwargs["dataset_config"]
306+
307+
# Type is preserved (this is the regression assertion)
308+
assert type(built_config) is _MarkerDatasetConfiguration
309+
# And carries the caller-supplied values, not the scenario defaults
310+
assert built_config.get_default_dataset_names() == ["custom_a", "custom_b"]
311+
assert built_config.max_dataset_size == 3
312+
# The original default config is not mutated when a fresh dataset_names is supplied
313+
assert default_config.get_default_dataset_names() == ["original"]
314+
assert default_config.max_dataset_size == 100
315+
316+
async def test_start_run_dataset_names_without_max_dataset_size_preserves_subclass(
317+
self, mock_all_registries
318+
) -> None:
319+
"""``dataset_names`` alone (no ``max_dataset_size``) still preserves the subclass type."""
320+
321+
class _MarkerDatasetConfiguration(DatasetConfiguration):
322+
pass
323+
324+
scenario_instance = mock_all_registries["scenario_instance"]
325+
scenario_instance._default_dataset_config = _MarkerDatasetConfiguration(dataset_names=["original"])
326+
327+
service = ScenarioRunService()
328+
await service.start_run_async(request=_make_request(dataset_names=["only_this"]))
329+
330+
init_call = scenario_instance.initialize_async.await_args
331+
built_config = init_call.kwargs["dataset_config"]
332+
assert type(built_config) is _MarkerDatasetConfiguration
333+
assert built_config.get_default_dataset_names() == ["only_this"]
334+
assert built_config.max_dataset_size is None
335+
336+
async def test_start_run_dataset_names_falls_back_when_subclass_constructor_incompatible(
337+
self, mock_all_registries, caplog
338+
) -> None:
339+
"""If the subclass __init__ rejects standard kwargs, fall back to plain ``DatasetConfiguration``."""
340+
341+
class _RequiresExtraArgConfiguration(DatasetConfiguration):
342+
def __init__(self, *, required_extra: str, **kwargs: Any) -> None:
343+
super().__init__(**kwargs)
344+
self._required_extra = required_extra
345+
346+
scenario_instance = mock_all_registries["scenario_instance"]
347+
# Build the default with the required kwarg so introspection succeeds.
348+
scenario_instance._default_dataset_config = _RequiresExtraArgConfiguration(
349+
required_extra="seeded", dataset_names=["original"]
350+
)
351+
352+
service = ScenarioRunService()
353+
with caplog.at_level("WARNING", logger=_svc_mod.logger.name):
354+
await service.start_run_async(request=_make_request(dataset_names=["custom"]))
355+
356+
init_call = scenario_instance.initialize_async.await_args
357+
built_config = init_call.kwargs["dataset_config"]
358+
359+
# Fallback is the generic base class, not the subclass
360+
assert type(built_config) is DatasetConfiguration
361+
assert built_config.get_default_dataset_names() == ["custom"]
362+
# Warning was logged so the operator can see the silent degradation
363+
assert any(
364+
"_RequiresExtraArgConfiguration" in record.message
365+
and "Falling back to a generic DatasetConfiguration" in record.message
366+
for record in caplog.records
367+
)
368+
369+
async def test_start_run_dataset_names_introspection_failure_raises(self, mock_memory) -> None:
370+
"""Passing ``dataset_names`` against a non-no-arg-instantiable scenario fails fast."""
371+
# Mirrors test_start_run_scenario_not_no_arg_instantiable_raises but for the dataset_names path.
372+
mock_scenario_class = MagicMock(
373+
side_effect=[
374+
TypeError("missing 1 required positional argument: 'objective_target'"),
375+
]
376+
)
377+
mock_sr = MagicMock()
378+
mock_sr.get_class.return_value = mock_scenario_class
379+
380+
mock_tr = MagicMock()
381+
mock_tr.get_instance_by_name.return_value = MagicMock()
382+
mock_tr.get_names.return_value = ["my_target"]
383+
384+
mock_ir = MagicMock()
385+
386+
service = ScenarioRunService()
387+
388+
with (
389+
patch(f"{_REGISTRY_PATCH_BASE}.ScenarioRegistry.get_registry_singleton", return_value=mock_sr),
390+
patch(f"{_REGISTRY_PATCH_BASE}.TargetRegistry.get_registry_singleton", return_value=mock_tr),
391+
patch(f"{_REGISTRY_PATCH_BASE}.InitializerRegistry.get_registry_singleton", return_value=mock_ir),
392+
):
393+
with pytest.raises(ValueError, match="not instantiable without arguments"):
394+
await service.start_run_async(request=_make_request(dataset_names=["custom"]))
395+
396+
async def test_start_run_max_dataset_size_with_dataset_names_uses_subclass_with_both(
397+
self, mock_all_registries
398+
) -> None:
399+
"""When both ``dataset_names`` and ``max_dataset_size`` are supplied, both flow into the subclass instance."""
400+
401+
class _MarkerDatasetConfiguration(DatasetConfiguration):
402+
pass
403+
404+
scenario_instance = mock_all_registries["scenario_instance"]
405+
scenario_instance._default_dataset_config = _MarkerDatasetConfiguration(
406+
dataset_names=["original"], max_dataset_size=99
407+
)
408+
409+
service = ScenarioRunService()
410+
await service.start_run_async(request=_make_request(dataset_names=["a", "b"], max_dataset_size=7))
411+
412+
built_config = scenario_instance.initialize_async.await_args.kwargs["dataset_config"]
413+
assert type(built_config) is _MarkerDatasetConfiguration
414+
assert built_config.get_default_dataset_names() == ["a", "b"]
415+
assert built_config.max_dataset_size == 7
416+
282417
async def test_start_run_exceeds_concurrent_limit(self, mock_all_registries) -> None:
283418
"""Test that exceeding concurrent run limit raises ValueError."""
284419
service = ScenarioRunService()

0 commit comments

Comments
 (0)