Skip to content

Commit f52b82c

Browse files
authored
Merge pull request #212 from launchdarkly/eb/sc-181822/preprocess-values
preprocess clause values for time/regex/semver operators
2 parents c8ace12 + 48fac41 commit f52b82c

File tree

6 files changed

+246
-153
lines changed

6 files changed

+246
-153
lines changed

ldclient/impl/evaluator.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,8 @@ def _rule_matches_context(self, rule: FlagRule, context: Context, state: EvalRes
202202
return True
203203

204204
def _clause_matches_context(self, clause: Clause, context: Context, state: EvalResult) -> bool:
205-
op = clause.op
206-
clause_values = clause.values
207-
if op == 'segmentMatch':
208-
for seg_key in clause_values:
205+
if clause.op == 'segmentMatch':
206+
for seg_key in clause.values:
209207
segment = self.__get_segment(seg_key)
210208
if segment is not None and self._segment_matches_context(segment, context, state):
211209
return _maybe_negate(clause, True)
@@ -226,10 +224,10 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
226224
# is the attr an array?
227225
if isinstance(context_value, (list, tuple)):
228226
for v in context_value:
229-
if _match_single_context_value(op, v, clause_values):
227+
if _match_single_context_value(clause, v):
230228
return _maybe_negate(clause, True)
231229
return _maybe_negate(clause, False)
232-
return _maybe_negate(clause, _match_single_context_value(op, context_value, clause_values))
230+
return _maybe_negate(clause, _match_single_context_value(clause, context_value))
233231

234232
def _segment_matches_context(self, segment: Segment, context: Context, state: EvalResult) -> bool:
235233
if state.segment_stack is not None and segment.key in state.segment_stack:
@@ -447,23 +445,24 @@ def _get_context_value_by_attr_ref(context: Context, attr: AttributeRef) -> Any:
447445
i += 1
448446
return value
449447

450-
def _match_single_context_value(op: str, context_value: Any, values: List[Any]) -> bool:
451-
op_fn = operators.ops.get(op)
448+
def _match_single_context_value(clause: Clause, context_value: Any) -> bool:
449+
op_fn = operators.ops.get(clause.op)
452450
if op_fn is None:
453451
return False
454-
for v in values:
455-
if op_fn(context_value, v):
452+
values_preprocessed = clause.values_preprocessed
453+
for i, v in enumerate(clause.values):
454+
preprocessed = None if values_preprocessed is None else values_preprocessed[i]
455+
if op_fn(context_value, v, preprocessed):
456456
return True
457457
return False
458458

459459
def _match_clause_by_kind(clause: Clause, context: Context) -> bool:
460460
# If attribute is "kind", then we treat operator and values as a match expression against a list
461461
# of all individual kinds in the context. That is, for a multi-kind context with kinds of "org"
462462
# and "user", it is a match if either of those strings is a match with Operator and Values.
463-
op = clause.op
464463
for i in range(context.individual_context_count):
465464
c = context.get_individual_context(i)
466-
if c is not None and _match_single_context_value(op, c.kind, clause.values):
465+
if c is not None and _match_single_context_value(clause, c.kind):
467466
return True
468467
return False
469468

ldclient/impl/model/clause.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,52 @@
1+
from re import Pattern
2+
from semver import VersionInfo
13
from typing import Any, List, Optional
24

35
from ldclient.impl.model.attribute_ref import AttributeRef, req_attr_ref_with_opt_context_kind
46
from ldclient.impl.model.entity import *
7+
from ldclient.impl.model.value_parsing import parse_regex, parse_semver, parse_time
58

9+
class ClausePreprocessedValue:
10+
__slots__ = ['_as_time', '_as_regex', '_as_semver']
11+
12+
def __init__(self, as_time: Optional[float]=None, as_regex: Optional[Pattern]=None, as_semver: Optional[VersionInfo]=None):
13+
self._as_time = as_time
14+
self._as_regex = as_regex
15+
self._as_semver = as_semver
16+
17+
@property
18+
def as_time(self) -> Optional[float]:
19+
return self._as_time
20+
21+
@property
22+
def as_regex(self) -> Optional[Pattern]:
23+
return self._as_regex
24+
25+
@property
26+
def as_semver(self) -> Optional[VersionInfo]:
27+
return self._as_semver
28+
29+
30+
def _preprocess_clause_values(op: str, values: List[Any]) -> Optional[List[ClausePreprocessedValue]]:
31+
if op == 'matches':
32+
return list(ClausePreprocessedValue(as_regex=parse_regex(value)) for value in values)
33+
if op == 'before' or op == 'after':
34+
return list(ClausePreprocessedValue(as_time=parse_time(value)) for value in values)
35+
if op == 'semVerEqual' or op == 'semVerGreaterThan' or op == 'semVerLessThan':
36+
return list(ClausePreprocessedValue(as_semver=parse_semver(value)) for value in values)
37+
return None
38+
39+
640
class Clause:
7-
__slots__ = ['_context_kind', '_attribute', '_op', '_values', '_negate']
41+
__slots__ = ['_context_kind', '_attribute', '_op', '_negate', '_values', '_values_preprocessed']
842

943
def __init__(self, data: dict):
1044
self._context_kind = opt_str(data, 'contextKind')
1145
self._attribute = req_attr_ref_with_opt_context_kind(req_str(data, 'attribute'), self._context_kind)
1246
self._negate = opt_bool(data, 'negate')
1347
self._op = req_str(data, 'op')
1448
self._values = req_list(data, 'values')
49+
self._values_preprocessed = _preprocess_clause_values(self._op, self._values)
1550

1651
@property
1752
def attribute(self) -> AttributeRef:
@@ -32,3 +67,7 @@ def op(self) -> str:
3267
@property
3368
def values(self) -> List[Any]:
3469
return self._values
70+
71+
@property
72+
def values_preprocessed(self) -> Optional[List[ClausePreprocessedValue]]:
73+
return self._values_preprocessed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import re
2+
from re import Pattern
3+
from semver import VersionInfo
4+
from datetime import tzinfo, timedelta, datetime
5+
from numbers import Number
6+
from typing import Any, Optional
7+
8+
import pyrfc3339
9+
10+
_ZERO = timedelta(0)
11+
12+
# A UTC class.
13+
14+
class _UTC(tzinfo):
15+
"""UTC"""
16+
17+
def utcoffset(self, dt):
18+
return _ZERO
19+
20+
def tzname(self, dt):
21+
return "UTC"
22+
23+
def dst(self, dt):
24+
return _ZERO
25+
26+
_epoch = datetime.utcfromtimestamp(0).replace(tzinfo=_UTC())
27+
28+
29+
def is_number(input: Any) -> bool:
30+
# bool is a subtype of int, and we don't want to try and treat it as a number.
31+
return isinstance(input, Number) and not isinstance(input, bool)
32+
33+
34+
def parse_regex(input: Any) -> Optional[Pattern]:
35+
if isinstance(input, str):
36+
try:
37+
return re.compile(input)
38+
except Exception:
39+
return None
40+
return None
41+
42+
43+
def parse_time(input: Any) -> Optional[float]:
44+
"""
45+
:param input: Either a number as milliseconds since Unix Epoch, or a string as a valid RFC3339 timestamp
46+
:return: milliseconds since Unix epoch, or None if input was invalid.
47+
"""
48+
49+
if is_number(input):
50+
return float(input)
51+
52+
if isinstance(input, str):
53+
try:
54+
parsed_time = pyrfc3339.parse(input)
55+
timestamp = (parsed_time - _epoch).total_seconds()
56+
return timestamp * 1000.0
57+
except Exception as e:
58+
return None
59+
60+
return None
61+
62+
def parse_semver(input: Any) -> Optional[VersionInfo]:
63+
if not isinstance(input, str):
64+
return None
65+
try:
66+
return VersionInfo.parse(input)
67+
except TypeError:
68+
return None
69+
except ValueError as e:
70+
try:
71+
input = _add_zero_version_component(input)
72+
return VersionInfo.parse(input)
73+
except ValueError as e:
74+
try:
75+
input = _add_zero_version_component(input)
76+
return VersionInfo.parse(input)
77+
return input
78+
except ValueError as e:
79+
return None
80+
81+
def _add_zero_version_component(input):
82+
m = re.search("^([0-9.]*)(.*)", input)
83+
if m is None:
84+
return input + ".0"
85+
return m.group(1) + ".0" + m.group(2)

0 commit comments

Comments
 (0)