11import dataclasses
22import logging
3- from collections .abc import Iterable
3+ from collections .abc import Callable , Iterable
44from typing import ClassVar , NoReturn , TypeVar
55
66import sentry_sdk
1717T = TypeVar ("T" )
1818
1919
20+ def _find_error (
21+ items : list ["TriggerResult" ], predicate : Callable [["TriggerResult" ], bool ]
22+ ) -> ConditionError | None :
23+ """Helper to find an error from items matching the predicate."""
24+ return next ((item .error for item in items if predicate (item )), None )
25+
26+
2027@dataclasses .dataclass (frozen = True )
2128class TriggerResult :
2229 """
@@ -39,36 +46,107 @@ class TriggerResult:
3946 TRUE : ClassVar ["TriggerResult" ]
4047 FALSE : ClassVar ["TriggerResult" ]
4148
49+ def is_tainted (self ) -> bool :
50+ """
51+ Returns True if this result is less trustworthy due to an error during
52+ evaluation.
53+ """
54+ return self .error is not None
55+
4256 @staticmethod
4357 def any (items : Iterable ["TriggerResult" ]) -> "TriggerResult" :
58+ """
59+ Like `any()`, but for TriggerResult. If any inputs had errors that could
60+ impact the result, the result will contain an error from one of them.
61+ """
4462 items_list = list (items )
45- for _ in (item for item in items_list if item .triggered and item .error is None ):
46- return TriggerResult (triggered = True , error = None )
47- # If we didn't have any untained Trues, the result is tainted.
48- some_error = next ((item .error for item in items_list if item .error is not None ), None )
49- return TriggerResult (triggered = any (item .triggered for item in items_list ), error = some_error )
63+ result = any (item .triggered for item in items_list )
64+
65+ if result :
66+ # Result is True. If we have any untainted True, the result is clean.
67+ # Only tainted if all Trues are tainted.
68+ if any (item .triggered and not item .is_tainted () for item in items_list ):
69+ return TriggerResult (triggered = True , error = None )
70+ # All Trues are tainted
71+ return TriggerResult (
72+ triggered = True , error = _find_error (items_list , lambda x : x .triggered )
73+ )
74+ else :
75+ # Result is False. Any tainted item could have changed the result.
76+ return TriggerResult (
77+ triggered = False ,
78+ error = _find_error (items_list , lambda x : x .is_tainted ()),
79+ )
5080
5181 @staticmethod
5282 def all (items : Iterable ["TriggerResult" ]) -> "TriggerResult" :
83+ """
84+ Like `all()`, but for TriggerResult. If any inputs had errors that could
85+ impact the result, the result will contain an error from one of them.
86+ """
87+ items_list = list (items )
88+ result = all (item .triggered for item in items_list )
89+
90+ if result :
91+ # Result is True. Any tainted item could have changed the result.
92+ return TriggerResult (
93+ triggered = True ,
94+ error = _find_error (items_list , lambda x : x .is_tainted ()),
95+ )
96+ else :
97+ # Result is False. If we have any untainted False, the result is clean.
98+ # Only tainted if all Falses are tainted.
99+ if any (not item .triggered and not item .is_tainted () for item in items_list ):
100+ return TriggerResult (triggered = False , error = None )
101+ # All Falses are tainted
102+ return TriggerResult (
103+ triggered = False ,
104+ error = _find_error (items_list , lambda x : not x .triggered ),
105+ )
106+
107+ @staticmethod
108+ def none (items : Iterable ["TriggerResult" ]) -> "TriggerResult" :
109+ """
110+ Like `not any()`, but for TriggerResult. If any inputs had errors that could
111+ impact the result, the result will contain an error from one of them.
112+ """
53113 items_list = list (items )
54- some_error = next ((item .error for item in items_list if item .error is not None ), None )
55- # if anything was tainted, the result is tainted.
56- return TriggerResult (
57- triggered = all (item .triggered for item in items_list ),
58- error = some_error ,
59- )
114+
115+ # No items is guaranteed True, no possible error.
116+ if not items_list :
117+ return TriggerResult (triggered = True , error = None )
118+
119+ result = all (not item .triggered for item in items_list )
120+
121+ if result :
122+ # Result is True (no conditions triggered)
123+ # Any tainted item could have changed the result
124+ return TriggerResult (
125+ triggered = True ,
126+ error = _find_error (items_list , lambda x : x .is_tainted ()),
127+ )
128+ else :
129+ # Result is False (at least one condition triggered)
130+ # If we have any untainted True, the result is clean
131+ if any (item .triggered and not item .is_tainted () for item in items_list ):
132+ return TriggerResult (triggered = False , error = None )
133+ # All triggered items are tainted
134+ return TriggerResult (
135+ triggered = False ,
136+ error = _find_error (items_list , lambda x : x .triggered ),
137+ )
60138
61139 def __or__ (self , other : "TriggerResult" ) -> "TriggerResult" :
62- return TriggerResult (
63- triggered = self . triggered or other . triggered ,
64- error = self . error or other . error ,
65- )
140+ """
141+ OR operation, equivalent to TriggerResult.any([self, other]).
142+ """
143+ return TriggerResult . any ([ self , other ] )
66144
67145 def __and__ (self , other : "TriggerResult" ) -> "TriggerResult" :
68- return TriggerResult (
69- triggered = self . triggered and other . triggered ,
70- error = self . error or other . error ,
71- )
146+ """
147+ AND operation, equivalent to TriggerResult.all([self, other]).
148+ """
149+ return TriggerResult . all ([ self , other ] )
72150
73151 def __bool__ (self ) -> NoReturn :
74152 raise AssertionError ("TriggerResult cannot be used as a boolean" )
@@ -146,7 +224,9 @@ def evaluate_condition_group_results(
146224 if logic_type == DataConditionGroup .Type .NONE :
147225 # if we get to this point, no conditions were met
148226 # because we would have short-circuited
149- logic_result = TriggerResult .TRUE
227+ logic_result = TriggerResult .none (
228+ condition_result .logic_result for condition_result in condition_results
229+ )
150230
151231 elif logic_type == DataConditionGroup .Type .ANY :
152232 logic_result = TriggerResult .any (
0 commit comments