Skip to content

Commit b34d891

Browse files
authored
Add new constraint operators (#192)
* Add (and load) new Constraint configuration. * Refactor constraint object to sparate logic. * Add string operators and inverted support. * Add numeric operators. * Support date operators and currentTime context value. * Add semver operator. * Add spec tests. * Install types. * Initialize semver vars. * Round 2 of spec changes. * Set default value. * Move constraint from default to Constraint.apply()
1 parent 01d7d2d commit b34d891

14 files changed

+1814
-42
lines changed

.github/workflows/pull_request.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
python setup.py install
2121
- name: Linting
2222
run: |
23-
mypy UnleashClient
23+
mypy UnleashClient --install-types --non-interactive
2424
pylint UnleashClient
2525
- name: Unit tests
2626
run: |

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## Next version
2+
* (Major) Support new constraint operators.
23
* (Major) Add cache abstraction. Thanks @walison17!
34
* (Minor) Refactor `unleash-client-python` to modernize tooling (`setuptools_scm` and centralizing tool config in `pyproject.toml`).
45
* (Minor) Migrate documentation to Sphinx.

UnleashClient/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# pylint: disable=invalid-name
22
import warnings
33
from datetime import datetime, timezone
4-
from typing import Dict, Callable, Any, Optional
5-
import copy
4+
from typing import Callable, Optional
65
from apscheduler.job import Job
76
from apscheduler.schedulers.background import BackgroundScheduler
87
from apscheduler.triggers.interval import IntervalTrigger
@@ -231,6 +230,8 @@ def is_enabled(self,
231230
:return: Feature flag result
232231
"""
233232
context = context or {}
233+
234+
# Update context with static values
234235
context.update(self.unleash_static_context)
235236

236237
if self.is_initialized:
+155-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,150 @@
1-
# pylint: disable=invalid-name, too-few-public-methods
1+
# pylint: disable=invalid-name, too-few-public-methods, use-a-generator
2+
from typing import Optional, Union
3+
from datetime import datetime
4+
from enum import Enum
5+
from dateutil.parser import parse, ParserError
6+
import semver
27
from UnleashClient.utils import LOGGER, get_identifier
38

49

10+
class ConstraintOperators(Enum):
11+
# Logical operators
12+
IN = "IN"
13+
NOT_IN = "NOT_IN"
14+
15+
# String operators
16+
STR_ENDS_WITH = "STR_ENDS_WITH"
17+
STR_STARTS_WITH = "STR_STARTS_WITH"
18+
STR_CONTAINS = "STR_CONTAINS"
19+
20+
# Numeric oeprators
21+
NUM_EQ = "NUM_EQ"
22+
NUM_GT = "NUM_GT"
23+
NUM_GTE = "NUM_GTE"
24+
NUM_LT = "NUM_LT"
25+
NUM_LTE = "NUM_LTE"
26+
27+
# Date operators
28+
DATE_AFTER = "DATE_AFTER"
29+
DATE_BEFORE = "DATE_BEFORE"
30+
31+
# Semver operators
32+
SEMVER_EQ = "SEMVER_EQ"
33+
SEMVER_GT = "SEMVER_GT"
34+
SEMVER_LT = "SEMVER_LT"
35+
36+
537
class Constraint:
638
def __init__(self, constraint_dict: dict) -> None:
739
"""
840
Represents a constraint on a strategy
941
1042
:param constraint_dict: From the strategy document.
1143
"""
12-
self.context_name = constraint_dict['contextName']
13-
self.operator = constraint_dict['operator']
14-
self.values = constraint_dict['values']
44+
self.context_name: str = constraint_dict['contextName']
45+
self.operator: ConstraintOperators = ConstraintOperators(constraint_dict['operator'].upper())
46+
self.values = constraint_dict['values'] if 'values' in constraint_dict.keys() else []
47+
self.value = constraint_dict['value'] if 'value' in constraint_dict.keys() else None
48+
49+
self.case_insensitive = constraint_dict['caseInsensitive'] if 'caseInsensitive' in constraint_dict.keys() else False
50+
self.inverted = constraint_dict['inverted'] if 'inverted' in constraint_dict.keys() else False
51+
52+
53+
# Methods to handle each operator type.
54+
def check_list_operators(self, context_value: str) -> bool:
55+
return_value = False
56+
57+
if self.operator == ConstraintOperators.IN:
58+
return_value = context_value in self.values
59+
elif self.operator == ConstraintOperators.NOT_IN:
60+
return_value = context_value not in self.values
61+
62+
return return_value
63+
64+
def check_string_operators(self, context_value: str) -> bool:
65+
if self.case_insensitive:
66+
normalized_values = [x.upper() for x in self.values]
67+
normalized_context_value = context_value.upper()
68+
else:
69+
normalized_values = self.values
70+
normalized_context_value = context_value
71+
72+
return_value = False
73+
74+
if self.operator == ConstraintOperators.STR_CONTAINS:
75+
return_value = any([x in normalized_context_value for x in normalized_values])
76+
elif self.operator == ConstraintOperators.STR_ENDS_WITH:
77+
return_value = any([normalized_context_value.endswith(x) for x in normalized_values])
78+
elif self.operator == ConstraintOperators.STR_STARTS_WITH:
79+
return_value = any([normalized_context_value.startswith(x) for x in normalized_values])
80+
81+
return return_value
82+
83+
def check_numeric_operators(self, context_value: Union[float, int]) -> bool:
84+
return_value = False
85+
parsed_value = float(self.value)
86+
87+
if self.operator == ConstraintOperators.NUM_EQ:
88+
return_value = context_value == parsed_value
89+
elif self.operator == ConstraintOperators.NUM_GT:
90+
return_value = context_value > parsed_value
91+
elif self.operator == ConstraintOperators.NUM_GTE:
92+
return_value = context_value >= parsed_value
93+
elif self.operator == ConstraintOperators.NUM_LT:
94+
return_value = context_value < parsed_value
95+
elif self.operator == ConstraintOperators.NUM_LTE:
96+
return_value = context_value <= parsed_value
97+
98+
return return_value
99+
100+
101+
def check_date_operators(self, context_value: datetime) -> bool:
102+
return_value = False
103+
parsing_exception = False
104+
105+
try:
106+
parsed_date = parse(self.value, ignoretz=True)
107+
except ParserError:
108+
LOGGER.error(f"Unable to parse date: {self.value}")
109+
parsing_exception = True
110+
111+
if not parsing_exception:
112+
if self.operator == ConstraintOperators.DATE_AFTER:
113+
return_value = context_value > parsed_date
114+
elif self.operator == ConstraintOperators.DATE_BEFORE:
115+
return_value = context_value < parsed_date
116+
117+
return return_value
118+
119+
120+
def check_semver_operators(self, context_value: str) -> bool:
121+
return_value = False
122+
parsing_exception = False
123+
target_version: Optional[semver.VersionInfo] = None
124+
context_version: Optional[semver.VersionInfo] = None
125+
126+
try:
127+
target_version = semver.VersionInfo.parse(self.value)
128+
except ValueError:
129+
LOGGER.error(f"Unable to parse server semver: {self.value}")
130+
parsing_exception = True
131+
132+
try:
133+
context_version = semver.VersionInfo.parse(context_value)
134+
except ValueError:
135+
LOGGER.error(f"Unable to parse context semver: {context_value}")
136+
parsing_exception = True
137+
138+
if not parsing_exception:
139+
if self.operator == ConstraintOperators.SEMVER_EQ:
140+
return_value = context_version == target_version
141+
elif self.operator == ConstraintOperators.SEMVER_GT:
142+
return_value = context_version > target_version
143+
elif self.operator == ConstraintOperators.SEMVER_LT:
144+
return_value = context_version < target_version
145+
146+
return return_value
147+
15148

16149
def apply(self, context: dict = None) -> bool:
17150
"""
@@ -23,14 +156,25 @@ def apply(self, context: dict = None) -> bool:
23156
constraint_check = False
24157

25158
try:
26-
value = get_identifier(self.context_name, context)
159+
context_value = get_identifier(self.context_name, context)
160+
161+
# Set currentTime if not specified
162+
if self.context_name == "currentTime" and not context_value:
163+
context_value = datetime.now()
164+
165+
if context_value is not None:
166+
if self.operator in [ConstraintOperators.IN, ConstraintOperators.NOT_IN]:
167+
constraint_check = self.check_list_operators(context_value=context_value)
168+
elif self.operator in [ConstraintOperators.STR_CONTAINS, ConstraintOperators.STR_ENDS_WITH, ConstraintOperators.STR_STARTS_WITH]:
169+
constraint_check = self.check_string_operators(context_value=context_value)
170+
elif self.operator in [ConstraintOperators.NUM_EQ, ConstraintOperators.NUM_GT, ConstraintOperators.NUM_GTE, ConstraintOperators.NUM_LT, ConstraintOperators.NUM_LTE]:
171+
constraint_check = self.check_numeric_operators(context_value=context_value)
172+
elif self.operator in [ConstraintOperators.DATE_AFTER, ConstraintOperators.DATE_BEFORE]:
173+
constraint_check = self.check_date_operators(context_value=context_value)
174+
elif self.operator in [ConstraintOperators.SEMVER_EQ, ConstraintOperators.SEMVER_GT, ConstraintOperators.SEMVER_LT]:
175+
constraint_check = self.check_semver_operators(context_value=context_value)
27176

28-
if value:
29-
if self.operator.upper() == "IN":
30-
constraint_check = value in self.values
31-
elif self.operator.upper() == "NOT_IN":
32-
constraint_check = value not in self.values
33177
except Exception as excep: # pylint: disable=broad-except
34178
LOGGER.info("Could not evaluate context %s! Error: %s", self.context_name, excep)
35179

36-
return constraint_check
180+
return not constraint_check if self.inverted else constraint_check

UnleashClient/utils.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from typing import Any
23
import mmh3 # pylint: disable=import-error
34
from requests import Response
45

@@ -14,7 +15,7 @@ def normalized_hash(identifier: str,
1415
return mmh3.hash(f"{activation_group}:{identifier}", signed=False) % normalizer + 1
1516

1617

17-
def get_identifier(context_key_name: str, context: dict) -> str:
18+
def get_identifier(context_key_name: str, context: dict) -> Any:
1819
if context_key_name in context.keys():
1920
value = context[context_key_name]
2021
elif 'properties' in context.keys() and context_key_name in context['properties'].keys():

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ disable = [
2020
"line-too-long",
2121
"missing-class-docstring",
2222
"missing-module-docstring",
23-
"missing-function-docstring"
23+
"missing-function-docstring",
24+
"logging-fstring-interpolation"
2425
]
2526
max-attributes = 25
2627
max-args = 25

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ requests
44
fcache
55
mmh3
66
APScheduler
7+
python-dateutil
8+
semver
79

810
# Development packages
911
bumpversion

setup.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ def readme():
1818
url='https://github.com/Unleash/unleash-client-python',
1919
packages=find_packages(exclude=["tests*"]),
2020
package_data={"UnleashClient": ["py.typed"]},
21-
install_requires=["requests", "fcache", "mmh3", "apscheduler", "importlib_metadata"],
21+
install_requires=[
22+
"requests",
23+
"fcache",
24+
"mmh3",
25+
"apscheduler",
26+
"importlib_metadata",
27+
"python-dateutil",
28+
"semver < 3.0.0"
29+
],
2230
setup_requires=['setuptools_scm'],
2331
zip_safe=False,
2432
include_package_data=True,

0 commit comments

Comments
 (0)