Skip to content

Commit

Permalink
Merge f3a285d into 7471490
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanklee86 committed Feb 4, 2022
2 parents 7471490 + f3a285d commit 7237623
Show file tree
Hide file tree
Showing 14 changed files with 1,449 additions and 42 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
python setup.py install
- name: Linting
run: |
mypy UnleashClient
mypy UnleashClient --install-types --non-interactive
pylint UnleashClient
- name: Unit tests
run: |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## Next version
* (Major) Support new constraint operators.
* (Minor) Refactor `unleash-client-python` to modernize tooling (`setuptools_scm` and centralizing tool config in `pyproject.toml`).
* (Minor) Migrate documentation to Sphinx.

Expand Down
9 changes: 7 additions & 2 deletions UnleashClient/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# pylint: disable=invalid-name
import warnings
from datetime import datetime, timezone
from typing import Dict, Callable, Any, Optional
import copy
from typing import Callable, Optional
from fcache.cache import FileCache
from apscheduler.job import Job
from apscheduler.schedulers.background import BackgroundScheduler
Expand Down Expand Up @@ -227,8 +226,14 @@ def is_enabled(self,
:return: Feature flag result
"""
context = context or {}

# Update context with static values
context.update(self.unleash_static_context)

# Update context with optional values
if 'currentTime' not in context.keys():
context.update({'currentTime': datetime.now()})

if self.is_initialized:
try:
return self.features[feature_name].is_enabled(context)
Expand Down
161 changes: 150 additions & 11 deletions UnleashClient/constraints/Constraint.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,149 @@
# pylint: disable=invalid-name, too-few-public-methods
# pylint: disable=invalid-name, too-few-public-methods, use-a-generator
from typing import Optional
from datetime import datetime
from enum import Enum
from dateutil.parser import parse, ParserError
import semver
from UnleashClient.utils import LOGGER, get_identifier


class ConstraintOperators(Enum):
# Logical operators
IN = "IN"
NOT_IN = "NOT_IN"

# String operators
STR_ENDS_WITH = "STR_ENDS_WITH"
STR_STARTS_WITH = "STR_STARTS_WITH"
STR_CONTAINS = "STR_CONTAINS"

# Numeric oeprators
NUM_EQ = "NUM_EQ"
NUM_GT = "NUM_GT"
NUM_GTE = "NUM_GTE"
NUM_LT = "NUM_LT"
NUM_LTE = "NUM_LTE"

# Date operators
DATE_AFTER = "DATE_AFTER"
DATE_BEFORE = "DATE_BEFORE"

# Semver operators
SEMVER_EQ = "SEMVER_EQ"
SEMVER_GT = "SEMVER_GT"
SEMVER_LT = "SEMVER_LT"


class Constraint:
def __init__(self, constraint_dict: dict) -> None:
"""
Represents a constraint on a strategy
:param constraint_dict: From the strategy document.
"""
self.context_name = constraint_dict['contextName']
self.operator = constraint_dict['operator']
self.values = constraint_dict['values']
self.context_name: str = constraint_dict['contextName']
self.operator: ConstraintOperators = ConstraintOperators(constraint_dict['operator'].upper())
self.values = constraint_dict['values'] if 'values' in constraint_dict.keys() else []
self.value = constraint_dict['value'] if 'value' in constraint_dict.keys() else []

self.case_insensitive = constraint_dict['caseInsensitive'] if 'caseInsensitive' in constraint_dict.keys() else False
self.inverted = constraint_dict['inverted'] if 'inverted' in constraint_dict.keys() else False


# Methods to handle each operator type.
def check_list_operators(self, context_value: str) -> bool:
return_value = False

if self.operator == ConstraintOperators.IN:
return_value = context_value in self.values
elif self.operator == ConstraintOperators.NOT_IN:
return_value = context_value not in self.values

return return_value

def check_string_operators(self, context_value: str) -> bool:
if self.case_insensitive:
normalized_values = [x.upper() for x in self.values]
normalized_context_value = context_value.upper()
else:
normalized_values = self.values
normalized_context_value = context_value

return_value = False

if self.operator == ConstraintOperators.STR_CONTAINS:
return_value = any([x in normalized_context_value for x in normalized_values])
elif self.operator == ConstraintOperators.STR_ENDS_WITH:
return_value = any([normalized_context_value.endswith(x) for x in normalized_values])
elif self.operator == ConstraintOperators.STR_STARTS_WITH:
return_value = any([normalized_context_value.startswith(x) for x in normalized_values])

return return_value

def check_numeric_operators(self, context_value: int) -> bool:
return_value = False

if self.operator == ConstraintOperators.NUM_EQ:
return_value = context_value == self.value
elif self.operator == ConstraintOperators.NUM_GT:
return_value = context_value > self.value
elif self.operator == ConstraintOperators.NUM_GTE:
return_value = context_value >= self.value
elif self.operator == ConstraintOperators.NUM_LT:
return_value = context_value < self.value
elif self.operator == ConstraintOperators.NUM_LTE:
return_value = context_value <= self.value

return return_value


def check_date_operators(self, context_value: datetime) -> bool:
return_value = False
parsing_exception = False

try:
parsed_date = parse(self.value, ignoretz=True)
except ParserError:
LOGGER.error(f"Unable to parse date: {self.value}")
parsing_exception = True

if not parsing_exception:
if self.operator == ConstraintOperators.DATE_AFTER:
return_value = context_value >= parsed_date
elif self.operator == ConstraintOperators.DATE_BEFORE:
return_value = context_value <= parsed_date

return return_value


def check_semver_operators(self, context_value: str) -> bool:
return_value = False
parsing_exception = False
target_version: Optional[semver.VersionInfo] = None
context_version: Optional[semver.VersionInfo] = None

try:
target_version = semver.VersionInfo.parse(self.value)
except ValueError:
LOGGER.error(f"Unable to parse server semver: {self.value}")
parsing_exception = True

try:
context_version = semver.VersionInfo.parse(context_value)
except ValueError:
LOGGER.error(f"Unable to parse context semver: {context_value}")
parsing_exception = True

if not parsing_exception:
if self.operator == ConstraintOperators.SEMVER_EQ:
return_value = context_version == target_version
elif self.operator == ConstraintOperators.SEMVER_GT:
return_value = context_version > target_version
elif self.operator == ConstraintOperators.SEMVER_LT:
return_value = context_version < target_version

return return_value


def apply(self, context: dict = None) -> bool:
"""
Expand All @@ -23,14 +155,21 @@ def apply(self, context: dict = None) -> bool:
constraint_check = False

try:
value = get_identifier(self.context_name, context)
context_value = get_identifier(self.context_name, context)

if context_value is not None:
if self.operator in [ConstraintOperators.IN, ConstraintOperators.NOT_IN]:
constraint_check = self.check_list_operators(context_value=context_value)
elif self.operator in [ConstraintOperators.STR_CONTAINS, ConstraintOperators.STR_ENDS_WITH, ConstraintOperators.STR_STARTS_WITH]:
constraint_check = self.check_string_operators(context_value=context_value)
elif self.operator in [ConstraintOperators.NUM_EQ, ConstraintOperators.NUM_GT, ConstraintOperators.NUM_GTE, ConstraintOperators.NUM_LT, ConstraintOperators.NUM_LTE]:
constraint_check = self.check_numeric_operators(context_value=context_value)
elif self.operator in [ConstraintOperators.DATE_AFTER, ConstraintOperators.DATE_BEFORE]:
constraint_check = self.check_date_operators(context_value=context_value)
elif self.operator in [ConstraintOperators.SEMVER_EQ, ConstraintOperators.SEMVER_GT, ConstraintOperators.SEMVER_LT]:
constraint_check = self.check_semver_operators(context_value=context_value)

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

return constraint_check
return not constraint_check if self.inverted else constraint_check
3 changes: 2 additions & 1 deletion UnleashClient/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Any
import mmh3 # pylint: disable=import-error
from requests import Response

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


def get_identifier(context_key_name: str, context: dict) -> str:
def get_identifier(context_key_name: str, context: dict) -> Any:
if context_key_name in context.keys():
value = context[context_key_name]
elif 'properties' in context.keys() and context_key_name in context['properties'].keys():
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ disable = [
"line-too-long",
"missing-class-docstring",
"missing-module-docstring",
"missing-function-docstring"
"missing-function-docstring",
"logging-fstring-interpolation"
]
max-attributes = 25
max-args = 25
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ requests
fcache
mmh3
APScheduler
python-dateutil
semver

# Development packages
bumpversion
Expand Down
10 changes: 9 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ def readme():
url='https://github.com/Unleash/unleash-client-python',
packages=find_packages(exclude=["tests*"]),
package_data={"UnleashClient": ["py.typed"]},
install_requires=["requests", "fcache", "mmh3", "apscheduler", "importlib_metadata"],
install_requires=[
"requests",
"fcache",
"mmh3",
"apscheduler",
"importlib_metadata",
"python-dateutil",
"semver < 3.0.0"
],
setup_requires=['setuptools_scm'],
zip_safe=False,
include_package_data=True,
Expand Down

0 comments on commit 7237623

Please sign in to comment.