Skip to content

Commit e07a8ef

Browse files
committed
refactor: Move shared evaluator helper functions into evaluator_common
Both evaluators duplicated ~150 lines of pure, stateless helper functions and constants verbatim; move them (with EvalResult/EvaluationException) to impl/evaluator_common.py imported by both. Drops now-dead imports (hashlib, operators, Any, Set). Touches released sync evaluator.py — behavior identical.
1 parent d38344d commit e07a8ef

3 files changed

Lines changed: 214 additions & 342 deletions

File tree

ldclient/impl/async_evaluator.py

Lines changed: 19 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
1-
import hashlib
21
import logging
3-
from typing import Any, Awaitable, Callable, Optional, Set, Tuple
2+
from typing import Awaitable, Callable, Optional, Tuple
43

54
from ldclient.context import Context
65
from ldclient.evaluation import BigSegmentsStatus, EvaluationDetail
7-
from ldclient.impl import operators
8-
from ldclient.impl.evaluator_common import EvalResult, EvaluationException
6+
from ldclient.impl.evaluator_common import (
7+
EvalResult,
8+
EvaluationException,
9+
_bucket_context,
10+
_bucketable_string_value,
11+
_context_key_is_in_target_list,
12+
_get_context_value_by_attr_ref,
13+
_get_off_value,
14+
_get_value_for_variation_or_rollout,
15+
_get_variation,
16+
_make_big_segment_ref,
17+
_match_clause_by_kind,
18+
_match_single_context_value,
19+
_maybe_negate,
20+
_target_match_result,
21+
_variation_index_for_context,
22+
error_reason
23+
)
924
from ldclient.impl.events.types import EventFactory
1025
from ldclient.impl.model import *
1126

1227
# For consistency with past logging behavior, we are pretending that the evaluation logic still lives in
1328
# the ldclient.evaluation module.
1429
log = logging.getLogger('ldclient.flag')
1530

16-
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
1731

18-
__BUILTINS__ = ["key", "secondary", "ip", "country", "email", "firstName", "lastName", "avatar", "name", "anonymous"]
19-
20-
21-
# EvalResult is used internally to hold the EvaluationDetail result of an evaluation along with
22-
# other side effects that are not exposed to the application, such as events generated by
23-
# prerequisite evaluations, and the cached state of any Big Segments query that we may have
24-
# ended up having to do for the context.
2532
class AsyncEvaluator:
2633
"""
2734
Async version of Evaluator. Encapsulates the feature flag evaluation logic. The AsyncEvaluator
@@ -291,162 +298,3 @@ async def _big_segment_match_context(self, segment: Segment, context: Context, s
291298
if included is not None:
292299
return included
293300
return await self._simple_segment_match_context(segment, context, state, False)
294-
295-
296-
# The following functions are declared outside AsyncEvaluator because they do not depend on any
297-
# of AsyncEvaluator's state. They are also used by the generated sync Evaluator.
298-
299-
300-
def _get_variation(flag: FeatureFlag, variation: int, reason: dict) -> EvaluationDetail:
301-
vars = flag.variations
302-
if variation < 0 or variation >= len(vars):
303-
return EvaluationDetail(None, None, error_reason('MALFORMED_FLAG'))
304-
return EvaluationDetail(vars[variation], variation, reason)
305-
306-
307-
def _get_off_value(flag: FeatureFlag, reason: dict) -> EvaluationDetail:
308-
off_var = flag.off_variation
309-
if off_var is None:
310-
return EvaluationDetail(None, None, reason)
311-
return _get_variation(flag, off_var, reason)
312-
313-
314-
def _get_value_for_variation_or_rollout(flag: FeatureFlag, vr: VariationOrRollout, context: Context, reason: dict) -> EvaluationDetail:
315-
index, inExperiment = _variation_index_for_context(flag, vr, context)
316-
if index is None:
317-
return EvaluationDetail(None, None, error_reason('MALFORMED_FLAG'))
318-
if inExperiment:
319-
reason['inExperiment'] = inExperiment
320-
return _get_variation(flag, index, reason)
321-
322-
323-
def _variation_index_for_context(flag: FeatureFlag, vr: VariationOrRollout, context: Context) -> Tuple[Optional[int], bool]:
324-
var = vr.variation
325-
if var is not None:
326-
return var, False
327-
328-
rollout = vr.rollout
329-
if rollout is None:
330-
return None, False
331-
variations = rollout.variations
332-
if len(variations) == 0:
333-
return None, False
334-
335-
bucket_by = None if rollout.is_experiment else rollout.bucket_by
336-
bucket = _bucket_context(rollout.seed, context, rollout.context_kind, flag.key, flag.salt, bucket_by)
337-
is_experiment = rollout.is_experiment and bucket >= 0
338-
# _bucket_context returns a negative value if the context didn't exist, in which case we
339-
# still end up returning the first bucket, but we will force the "in experiment" state to be false.
340-
341-
sum = 0.0
342-
for wv in variations:
343-
sum += wv.weight / 100000.0
344-
if bucket < sum:
345-
is_experiment_partition = is_experiment and not wv.untracked
346-
return wv.variation, is_experiment_partition
347-
348-
# The context's bucket value was greater than or equal to the end of the last bucket. This could happen due
349-
# to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag
350-
# data could contain buckets that don't actually add up to 100000. Rather than returning an error in
351-
# this case (or changing the scaling, which would potentially change the results for *all* contexts), we
352-
# will simply put the context in the last bucket.
353-
is_experiment_partition = is_experiment and not variations[-1].untracked
354-
return variations[-1].variation, is_experiment_partition
355-
356-
357-
def _bucket_context(seed: Optional[int], context: Context, context_kind: Optional[str], key: str, salt: str, bucket_by: Optional[AttributeRef]) -> float:
358-
match_context = context.get_individual_context(context_kind or Context.DEFAULT_KIND)
359-
if match_context is None:
360-
return -1
361-
clause_value = match_context.key if bucket_by is None else _get_context_value_by_attr_ref(match_context, bucket_by)
362-
if clause_value is None:
363-
return 0.0
364-
bucket_by_value = _bucketable_string_value(clause_value)
365-
if bucket_by_value is None:
366-
return 0.0
367-
id_hash = clause_value
368-
if seed is not None:
369-
prefix = str(seed)
370-
else:
371-
prefix = '%s.%s' % (key, salt)
372-
hash_key = '%s.%s' % (prefix, id_hash)
373-
hash_val = int(hashlib.sha1(hash_key.encode('utf-8')).hexdigest()[:15], 16)
374-
result = hash_val / __LONG_SCALE__
375-
return result
376-
377-
378-
def _bucketable_string_value(u_value) -> Optional[str]:
379-
if isinstance(u_value, bool):
380-
return None
381-
elif isinstance(u_value, (str, int)):
382-
return str(u_value)
383-
384-
return None
385-
386-
387-
def _context_key_is_in_target_list(context: Context, context_kind: Optional[str], keys: Set[str]) -> bool:
388-
if keys is None or len(keys) == 0:
389-
return False
390-
match_context = context.get_individual_context(context_kind or Context.DEFAULT_KIND)
391-
return match_context is not None and match_context.key in keys
392-
393-
394-
def _get_context_value_by_attr_ref(context: Context, attr: AttributeRef) -> Any:
395-
if attr is None:
396-
raise EvaluationException("rule clause did not specify an attribute")
397-
if attr.error is not None:
398-
raise EvaluationException("invalid attribute reference: " + attr.error)
399-
name = attr[0]
400-
if name is None:
401-
return None
402-
value = context.get(name)
403-
depth = attr.depth
404-
i = 1
405-
while i < depth:
406-
if not isinstance(value, dict):
407-
return None # can't get subproperty if we're not in a JSON object
408-
value = value.get(attr[i])
409-
i += 1
410-
return value
411-
412-
413-
def _match_single_context_value(clause: Clause, context_value: Any) -> bool:
414-
op_fn = operators.ops.get(clause.op)
415-
if op_fn is None:
416-
return False
417-
values_preprocessed = clause.values_preprocessed
418-
for i, v in enumerate(clause.values):
419-
preprocessed = None if values_preprocessed is None else values_preprocessed[i]
420-
if op_fn(context_value, v, preprocessed):
421-
return True
422-
return False
423-
424-
425-
def _match_clause_by_kind(clause: Clause, context: Context) -> bool:
426-
# If attribute is "kind", then we treat operator and values as a match expression against a list
427-
# of all individual kinds in the context. That is, for a multi-kind context with kinds of "org"
428-
# and "user", it is a match if either of those strings is a match with Operator and Values.
429-
for i in range(context.individual_context_count):
430-
c = context.get_individual_context(i)
431-
if c is not None and _match_single_context_value(clause, c.kind):
432-
return True
433-
return False
434-
435-
436-
def _maybe_negate(clause: Clause, val: bool) -> bool:
437-
return not val if clause.negate else val
438-
439-
440-
def _make_big_segment_ref(segment: Segment) -> str:
441-
# The format of Big Segment references is independent of what store implementation is being
442-
# used; the store implementation receives only this string and does not know the details of
443-
# the data model. The Relay Proxy will use the same format when writing to the store.
444-
return "%s.g%d" % (segment.key, segment.generation or 0)
445-
446-
447-
def _target_match_result(flag: FeatureFlag, var: int) -> EvaluationDetail:
448-
return _get_variation(flag, var, {'kind': 'TARGET_MATCH'})
449-
450-
451-
def error_reason(error_kind: str) -> dict:
452-
return {'kind': 'ERROR', 'errorKind': error_kind}

0 commit comments

Comments
 (0)