11from __future__ import annotations
22
3- from datetime import datetime , timezone
3+ from typing import TYPE_CHECKING
4+
5+ import numpy as np
6+ import pandas as pd
47
58from sift_client .sift_types .test_report import (
69 NumericBounds ,
1013 TestMeasurementUpdate ,
1114)
1215
16+ if TYPE_CHECKING :
17+ from numpy .typing import NDArray
18+
19+
20+ def to_numpy_array (
21+ values : list [float | int ] | NDArray [np .float64 ] | pd .Series ,
22+ ) -> NDArray [np .float64 ]:
23+ """Normalize a list / ndarray / pandas Series into a numpy array.
24+
25+ Shared by ``measure_avg`` and ``measure_all`` in both the real plugin and
26+ the no-op sibling so the accepted input types stay in sync.
27+ """
28+ if isinstance (values , list ):
29+ return np .array (values )
30+ if isinstance (values , np .ndarray ):
31+ return values
32+ if isinstance (values , pd .Series ):
33+ return values .to_numpy ()
34+ raise ValueError (f"Invalid value type: { type (values )} " )
35+
36+
37+ def out_of_bounds_mask (
38+ arr : NDArray [np .float64 ],
39+ bounds : dict [str , float ] | NumericBounds ,
40+ ) -> NDArray [np .bool_ ]:
41+ """Return a boolean mask selecting elements of ``arr`` that violate ``bounds``.
42+
43+ Raises ``ValueError`` when ``bounds`` has neither ``min`` nor ``max`` set.
44+ """
45+ if isinstance (bounds , dict ):
46+ bounds = NumericBounds (min = bounds .get ("min" ), max = bounds .get ("max" ))
47+ mask : NDArray [np .bool_ ] | None = None
48+ if bounds .min is not None :
49+ mask = arr < bounds .min
50+ if bounds .max is not None :
51+ above = arr > bounds .max
52+ mask = mask | above if mask is not None else above
53+ if mask is None :
54+ raise ValueError ("No bounds provided" )
55+ return mask
56+
57+
58+ def all_within_bounds (
59+ arr : NDArray [np .float64 ],
60+ bounds : dict [str , float ] | NumericBounds ,
61+ ) -> bool :
62+ """Return True when every element of ``arr`` is within ``bounds``."""
63+ return bool (arr [out_of_bounds_mask (arr , bounds )].size == 0 )
64+
1365
1466def assign_value_to_measurement (
1567 measurement : TestMeasurement | TestMeasurementCreate | TestMeasurementUpdate ,
@@ -43,13 +95,31 @@ def value_passes_bounds(
4395 Used by consumers that need pass/fail semantics matching the real plugin but
4496 do not transmit a measurement (e.g. ``sift_client.pytest_plugin_noop``).
4597 """
46- scratch = TestMeasurementCreate (
47- name = "" ,
48- test_step_id = "" ,
49- passed = True ,
50- timestamp = datetime .now (timezone .utc ),
51- )
52- return evaluate_measurement_bounds (scratch , value , bounds )
98+ if bounds is None :
99+ return True
100+ if isinstance (bounds , dict ):
101+ bounds = NumericBounds (min = bounds .get ("min" ), max = bounds .get ("max" ))
102+ if isinstance (bounds , bool ):
103+ if isinstance (value , str ):
104+ return str (value ).lower () == str (bounds ).lower ()
105+ return bool (value ) == bounds
106+ if isinstance (bounds , str ):
107+ if not (isinstance (value , str ) or isinstance (value , bool )):
108+ raise ValueError ("Value must be a string if bounds provided is a string" )
109+ if isinstance (value , bool ):
110+ return str (value ).lower () == str (bounds ).lower ()
111+ return value == bounds
112+ # NumericBounds
113+ try :
114+ if bounds .min is not None and bounds .min > value : # type: ignore[operator]
115+ return False
116+ if bounds .max is not None and bounds .max < value : # type: ignore[operator]
117+ return False
118+ except TypeError :
119+ raise TypeError (
120+ f"Value must be a float or int to evaluate numeric bounds but gave { type (value )} "
121+ ) from None
122+ return True
53123
54124
55125def evaluate_measurement_bounds (
@@ -73,31 +143,10 @@ def evaluate_measurement_bounds(
73143
74144 if isinstance (bounds , dict ):
75145 bounds = NumericBounds (min = bounds .get ("min" ), max = bounds .get ("max" ))
76- if isinstance (bounds , bool ):
77- if isinstance (value , str ):
78- measurement .passed = str (value ).lower () == str (bounds ).lower ()
79- else :
80- measurement .passed = bool (value ) == bounds
81- return bool (measurement .passed )
82- elif isinstance (bounds , str ):
83- if not (isinstance (value , str ) or isinstance (value , bool )):
84- raise ValueError ("Value must be a string if bounds provided is a string" )
146+ if isinstance (bounds , str ) and not isinstance (bounds , bool ):
85147 measurement .string_expected_value = bounds
86- if isinstance (value , bool ):
87- measurement .passed = str (value ).lower () == str (bounds ).lower ()
88- else :
89- measurement .passed = value == bounds
90148 elif isinstance (bounds , NumericBounds ):
91149 measurement .numeric_bounds = bounds
92- measurement .passed = True
93- try :
94- if measurement .numeric_bounds .min is not None :
95- measurement .passed = measurement .passed and measurement .numeric_bounds .min <= value # type: ignore
96- if measurement .numeric_bounds .max is not None :
97- measurement .passed = measurement .passed and measurement .numeric_bounds .max >= value # type: ignore
98- except TypeError :
99- raise TypeError (
100- f"Value must be a float or int to evaluate numeric bounds but gave { type (value )} "
101- ) from None
102150
151+ measurement .passed = value_passes_bounds (value , bounds )
103152 return bool (measurement .passed )
0 commit comments