88from eval_protocol .pytest .types import (
99 Dataset ,
1010 DatasetPathParam ,
11+ EvaluationTestMode ,
1112 InputMessagesParam ,
1213 InputParam ,
1314 ModelParam ,
@@ -44,6 +45,39 @@ def _aggregate(scores: List[float], method: str) -> float:
4445 raise ValueError (f"Unknown aggregation method: { method } " )
4546
4647
48+ def _create_dynamically_parameterized_wrapper (test_func , wrapper_body , test_param_names ):
49+ """
50+ Creates a wrapper function with dynamic parameters for pytest parameterization.
51+
52+ This function takes a test function and creates a wrapper that:
53+ 1. Preserves the original function's metadata using functools.wraps
54+ 2. Creates a new function signature with the specified parameter names that maps to pytest.mark.parametrize decorator
55+ 3. Returns a callable that can be used with pytest.mark.parametrize
56+
57+ The function signature is dynamically created to match the parameter names expected by
58+ pytest.mark.parametrize, ensuring that pytest can properly map the test parameters
59+ to the function arguments.
60+
61+ Args:
62+ test_func: The original test function to wrap
63+ wrapper_body: The function body that contains the actual test logic
64+ test_param_names: List of parameter names for the dynamic signature
65+
66+ Returns:
67+ A wrapper function with the specified parameter signature that calls wrapper_body
68+ """
69+ from functools import wraps
70+
71+ @wraps (test_func )
72+ def wrapper (** kwargs ):
73+ return wrapper_body (** kwargs )
74+
75+ parameters = [inspect .Parameter (name , inspect .Parameter .POSITIONAL_OR_KEYWORD ) for name in test_param_names ]
76+ wrapper .__signature__ = inspect .Signature (parameters )
77+
78+ return wrapper
79+
80+
4781def evaluation_test (
4882 * ,
4983 model : List [ModelParam ],
@@ -57,7 +91,7 @@ def evaluation_test(
5791 num_runs : int = 1 ,
5892 max_dataset_rows : Optional [int ] = None ,
5993 mcp_config_path : Optional [str ] = None ,
60- mode : str = "batch" , # "batch" (default) or "pointwise"/"rowwise"
94+ mode : EvaluationTestMode = "batch" ,
6195) -> Callable [
6296 [TestFunction ],
6397 TestFunction ,
@@ -82,7 +116,8 @@ def evaluation_test(
82116 num_runs: Number of times to repeat the evaluation.
83117 max_dataset_rows: Limit dataset to the first N rows.
84118 mode: Evaluation mode. "batch" (default) expects test function to handle
85- full dataset. "pointwise"/"rowwise" applies test function to each row.
119+ full dataset. "pointwise" applies test function to each row. If your evaluation requires
120+ the full rollout of all rows to compute the score, use
86121
87122 Usage:
88123 With an input dataset and input params, the test function will be called with the following arguments:
@@ -128,22 +163,31 @@ def decorator(
128163 is_async = inspect .iscoroutinefunction (test_func )
129164
130165 sig = inspect .signature (test_func )
131-
166+
132167 # For pointwise/rowwise mode, we expect a different signature
133- if mode in [ "pointwise" , "rowwise" ] :
168+ if mode == "pointwise" :
134169 # Pointwise mode: function should accept messages and other row-level params
135- if "messages" not in sig .parameters :
136- raise ValueError (f"In { mode } mode, test_func must have a parameter named 'messages'" )
170+ if "row" not in sig .parameters :
171+ raise ValueError (f"In pointwise mode, your eval function must have a parameter named 'row'" )
172+
173+ # validate that the function has a return type of EvaluationRow
174+ if sig .return_annotation is not EvaluationRow :
175+ raise ValueError ("In pointwise mode, your eval function must return an EvaluationRow instance" )
137176 else :
138177 # Batch mode: function should accept input_dataset and model
139178 if "input_dataset" not in sig .parameters :
140- raise ValueError ("test_func must have a parameter named 'input_dataset'" )
179+ raise ValueError ("In batch mode, your eval function must have a parameter named 'input_dataset'" )
141180 if "model" not in sig .parameters :
142- raise ValueError ("test_func must have a parameter named 'model'" )
181+ raise ValueError ("In batch mode, your eval function must have a parameter named 'model'" )
182+
183+ # validate that the function has a return type of List[EvaluationRow]
184+ if sig .return_annotation is not List [EvaluationRow ]:
185+ raise ValueError ("In batch mode, your eval function must return a list of EvaluationRow instances" )
143186
144187 def execute_with_params (
145188 test_func : TestFunction ,
146189 model : str ,
190+ row : EvaluationRow | None = None ,
147191 input_dataset : List [EvaluationRow ] | None = None ,
148192 input_params : InputParam | None = None ,
149193 ):
@@ -154,6 +198,8 @@ def execute_with_params(
154198 kwargs ["input_params" ] = input_params
155199 if model is not None :
156200 kwargs ["model" ] = model
201+ if row is not None :
202+ kwargs ["row" ] = row
157203 if is_async :
158204 # Handle async functions with proper event loop management
159205 try :
@@ -173,19 +219,16 @@ def execute_with_params(
173219 return results
174220
175221 # Calculate all possible combinations of parameters
176- def generate_combinations (model : List [ ModelParam ] ):
222+ def generate_combinations ():
177223 combinations = []
178224
179- # Always include models
180- model_list = model
181-
182225 # Handle optional parameters with defaults
183226 datasets : List [Optional [DatasetPathParam ]] = input_dataset if input_dataset is not None else [None ] # type: ignore
184227 params : List [Optional [InputParam ]] = input_params if input_params is not None else [None ] # type: ignore
185228 messages : List [Optional [InputMessagesParam ]] = input_messages if input_messages is not None else [None ] # type: ignore
186229
187230 # Generate all combinations
188- for m in model_list :
231+ for m in model :
189232 for ds in datasets :
190233 for ip in params :
191234 for im in messages :
@@ -199,7 +242,7 @@ def generate_combinations(model: List[ModelParam]):
199242
200243 return combinations
201244
202- combinations = generate_combinations (model )
245+ combinations = generate_combinations ()
203246
204247 # Create parameter tuples for pytest.mark.parametrize
205248 param_tuples = []
@@ -214,23 +257,14 @@ def generate_combinations(model: List[ModelParam]):
214257 param_tuple .append (messages )
215258 param_tuples .append (tuple (param_tuple ))
216259
217- # Determine the parameter names for the test function
218- if mode in ["pointwise" , "rowwise" ]:
219- # For pointwise mode, we generate simpler parameter names
220- test_param_names = ["model" ]
221- if input_dataset is not None :
222- test_param_names .append ("dataset_path" )
223- if input_params is not None :
224- test_param_names .append ("input_params" )
225- else :
226- # For batch mode, use the original parameter names
227- test_param_names = ["model" ]
228- if input_dataset is not None :
229- test_param_names .append ("dataset_path" )
230- if input_params is not None :
231- test_param_names .append ("input_params" )
232- if input_messages is not None :
233- test_param_names .append ("input_messages" )
260+ # For batch mode, use the original parameter names
261+ test_param_names = ["model" ]
262+ if input_dataset is not None :
263+ test_param_names .append ("dataset_path" )
264+ if input_params is not None :
265+ test_param_names .append ("input_params" )
266+ if input_messages is not None :
267+ test_param_names .append ("input_messages" )
234268
235269 # Create wrapper function with exact signature that pytest expects
236270 def create_wrapper_with_signature ():
@@ -276,9 +310,20 @@ def wrapper_body(**kwargs):
276310
277311 all_results : List [EvaluationRow ] = []
278312 for _ in range (num_runs ):
279- if mode in [ "pointwise" , "rowwise" ] :
313+ if mode == "pointwise" :
280314 # Pointwise mode: apply the evaluator function to each row
281- results = evaluate (input_dataset , test_func )
315+ for row in input_dataset :
316+ result = execute_with_params (
317+ test_func ,
318+ model = model_name ,
319+ row = row ,
320+ input_params = kwargs .get ("input_params" ) if "input_params" in kwargs else None ,
321+ )
322+ if result is None or not isinstance (result , EvaluationRow ):
323+ raise ValueError (
324+ f"Test function { test_func .__name__ } did not return an EvaluationRow instance. You must return an EvaluationRow instance from your test function decorated with @evaluation_test."
325+ )
326+ all_results .append (result )
282327 else :
283328 # Batch mode: call the test function with the full dataset
284329 results = execute_with_params (
@@ -303,7 +348,7 @@ def wrapper_body(**kwargs):
303348 raise ValueError (
304349 f"Test function { test_func .__name__ } returned a list containing non-EvaluationRow instances. You must return a list of EvaluationRow instances from your test function decorated with @evaluation_test."
305350 )
306- all_results .extend (results )
351+ all_results .extend (results )
307352
308353 scores = [r .evaluation_result .score for r in all_results if r .evaluation_result ]
309354 agg_score = _aggregate (scores , aggregation_method )
@@ -312,33 +357,7 @@ def wrapper_body(**kwargs):
312357 agg_score >= threshold_of_success
313358 ), f"Aggregated score { agg_score :.3f} below threshold { threshold_of_success } "
314359
315- # Create a function with the exact signature pytest expects without using exec
316- from functools import wraps
317-
318- if mode in ["pointwise" , "rowwise" ]:
319- # For pointwise mode, create a wrapper that handles everything internally
320- @wraps (test_func )
321- def wrapper (** kwargs ):
322- return wrapper_body (** kwargs )
323-
324- # Give it the pytest-compatible signature but the test_func name
325- parameters = [
326- inspect .Parameter (name , inspect .Parameter .POSITIONAL_OR_KEYWORD ) for name in test_param_names
327- ]
328- wrapper .__signature__ = inspect .Signature (parameters )
329- wrapper .__name__ = test_func .__name__ .replace ('_evaluate' , '_dataset' ) if '_evaluate' in test_func .__name__ else f"test_{ test_func .__name__ } "
330- else :
331- # For batch mode, use the original wrapper
332- @wraps (test_func )
333- def wrapper (** kwargs ):
334- return wrapper_body (** kwargs )
335-
336- parameters = [
337- inspect .Parameter (name , inspect .Parameter .POSITIONAL_OR_KEYWORD ) for name in test_param_names
338- ]
339- wrapper .__signature__ = inspect .Signature (parameters )
340-
341- return wrapper
360+ return _create_dynamically_parameterized_wrapper (test_func , wrapper_body , test_param_names )
342361
343362 wrapper = create_wrapper_with_signature ()
344363 wrapper = pytest .mark .parametrize (test_param_names , param_tuples )(wrapper )
0 commit comments