Skip to content
This repository has been archived by the owner on Nov 8, 2024. It is now read-only.

FF-1677 Python SDK UFC update #28

Merged
merged 40 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
72d1ee5
wip
schmit Mar 5, 2024
35b6151
add todo on timestamps
schmit Mar 5, 2024
a3a4833
fix linting errors
schmit Mar 5, 2024
aed5425
wip
schmit Mar 5, 2024
8ba3330
wip
schmit Mar 5, 2024
26006ec
wip
schmit Mar 5, 2024
36a3e18
address comments
schmit Mar 6, 2024
0df2809
wip
schmit Mar 14, 2024
571c2c7
update tests
schmit Mar 14, 2024
c698818
update tests and json
schmit Mar 15, 2024
209bfea
update tests
schmit Mar 15, 2024
bdcbb61
move value type to flag
schmit Mar 15, 2024
7cae3d5
fixes
schmit Mar 15, 2024
a95e0d8
update makefile to point to ufc tests
schmit Mar 15, 2024
4252e78
update version
schmit Mar 15, 2024
9f8f7f7
flake8 + mypy fixes
schmit Mar 15, 2024
6d38056
inject id into subject attributes
schmit Mar 15, 2024
17f09b5
Address Giorgio's comments
schmit Mar 19, 2024
a517779
fix check_type_match
schmit Mar 19, 2024
b231589
eval always returns a FlagEvaluation
schmit Mar 20, 2024
502f8f3
add type signatures
schmit Mar 20, 2024
2ba14c1
more typing
schmit Mar 21, 2024
6a0f3d3
use numeric instead of float
schmit Mar 21, 2024
6ec21dd
more typing
schmit Mar 21, 2024
e55e88f
more careful about rules and tests
schmit Mar 21, 2024
5cb9830
Address Giorgio's comments
schmit Mar 21, 2024
7897a67
fix typing error in none_result
schmit Mar 22, 2024
0506aaa
add NOT_MATCHES operator
schmit Mar 22, 2024
de969b0
get_integer_assignment should return an integer
schmit Mar 23, 2024
dcbe36e
update types in client.py
schmit Mar 25, 2024
b0ad9c1
use numeric instead of float
schmit Mar 28, 2024
8248353
Fix UFC tests, force default value
schmit Apr 3, 2024
9fda450
add metadata logging and is_initialized
schmit Apr 4, 2024
2c0b6cf
update endpoint
schmit Apr 12, 2024
1b830d8
Python UFC SDK updates (#29)
schmit Apr 12, 2024
3931550
Update function signatures
schmit Apr 18, 2024
6f35c5a
added semver tests
schmit Apr 22, 2024
82bfa59
return noneresult more often
schmit Apr 22, 2024
4864085
add sharders test
schmit Apr 22, 2024
67c439f
:broom:
schmit Apr 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions eppo_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from eppo_client.client import EppoClient
from eppo_client.config import Config
from eppo_client.configuration_requestor import (
ExperimentConfigurationDto,
ExperimentConfigurationRequestor,
)
from eppo_client.configuration_store import ConfigurationStore
from eppo_client.constants import MAX_CACHE_ENTRIES
from eppo_client.http_client import HttpClient, SdkParams
from eppo_client.models import Flag
from eppo_client.read_write_lock import ReadWriteLock

__version__ = "1.3.1"
schmit marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -31,7 +31,7 @@ def init(config: Config) -> EppoClient:
apiKey=config.api_key, sdkName="python", sdkVersion=__version__
)
http_client = HttpClient(base_url=config.base_url, sdk_params=sdk_params)
config_store: ConfigurationStore[ExperimentConfigurationDto] = ConfigurationStore(
config_store: ConfigurationStore[Flag] = ConfigurationStore(
max_size=MAX_CACHE_ENTRIES
)
config_requestor = ExperimentConfigurationRequestor(
Expand Down
160 changes: 52 additions & 108 deletions eppo_client/client.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import hashlib
import datetime
import logging
from typing import Any, Dict, Optional
from typing_extensions import deprecated
from numbers import Number
from eppo_client.assignment_logger import AssignmentLogger
from eppo_client.configuration_requestor import (
ExperimentConfigurationDto,
ExperimentConfigurationRequestor,
VariationDto,
)
from eppo_client.constants import POLL_INTERVAL_MILLIS, POLL_JITTER_MILLIS
from eppo_client.poller import Poller
from eppo_client.rules import find_matching_rule
from eppo_client.shard import ShardRange, get_shard, is_in_shard_range
from eppo_client.sharding import MD5Sharder
from eppo_client.validation import validate_not_blank
from eppo_client.variation_type import VariationType
from eppo_client.eval import FlagEvaluation, Evaluator
from eppo_client.models import Variation

logger = logging.getLogger(__name__)

Expand All @@ -36,9 +34,10 @@ def __init__(
callback=config_requestor.fetch_and_store_configurations,
)
self.__poller.start()
self.__evaluator = Evaluator(sharder=MD5Sharder())

def get_string_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
self, subject_key: str, flag_key: str, subject_attributes=dict(), default=None
) -> Optional[str]:
giorgiomartini0 marked this conversation as resolved.
Show resolved Hide resolved
try:
assigned_variation = self.get_assignment_variation(
Expand All @@ -55,11 +54,15 @@ def get_string_assignment(
except Exception as e:
if self.__is_graceful_mode:
logger.error("[Eppo SDK] Error getting assignment: " + str(e))
return None
return default
raise e

def get_numeric_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
self,
subject_key: str,
flag_key: str,
subject_attributes=dict(),
default=None,
schmit marked this conversation as resolved.
Show resolved Hide resolved
) -> Optional[Number]:
try:
assigned_variation = self.get_assignment_variation(
Expand All @@ -76,11 +79,15 @@ def get_numeric_assignment(
except Exception as e:
if self.__is_graceful_mode:
logger.error("[Eppo SDK] Error getting assignment: " + str(e))
return None
return default
raise e

def get_boolean_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
self,
subject_key: str,
flag_key: str,
subject_attributes=dict(),
default=None,
) -> Optional[bool]:
try:
assigned_variation = self.get_assignment_variation(
Expand All @@ -97,11 +104,15 @@ def get_boolean_assignment(
except Exception as e:
if self.__is_graceful_mode:
logger.error("[Eppo SDK] Error getting assignment: " + str(e))
return None
return default
raise e

def get_parsed_json_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
self,
subject_key: str,
flag_key: str,
subject_attributes=dict(),
default=None,
) -> Optional[Dict[Any, Any]]:
try:
assigned_variation = self.get_assignment_variation(
Expand All @@ -118,13 +129,17 @@ def get_parsed_json_assignment(
except Exception as e:
if self.__is_graceful_mode:
logger.error("[Eppo SDK] Error getting assignment: " + str(e))
return None
return default
raise e

def get_json_string_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
self, subject_key: str, flag_key: str, subject_attributes=dict(), default=None
) -> Optional[str]:
try:
result = self.get_assignment_detail(
subject_key, flag_key, subject_attributes
)
assigned_variation = result.variation
assigned_variation = self.get_assignment_variation(
subject_key, flag_key, subject_attributes, VariationType.JSON
)
Expand All @@ -139,19 +154,20 @@ def get_json_string_assignment(
except Exception as e:
if self.__is_graceful_mode:
logger.error("[Eppo SDK] Error getting assignment: " + str(e))
return None
return default
raise e

@deprecated(
"get_assignment is deprecated in favor of the typed get_<type>_assignment methods"
)
def get_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
self, subject_key: str, flag_key: str, subject_attributes=dict(), default=None
) -> Optional[str]:
try:
assigned_variation = self.get_assignment_variation(
result = self.get_assignment_detail(
subject_key, flag_key, subject_attributes
)
assigned_variation = result.variation
return (
assigned_variation.value
if assigned_variation is not None
Expand All @@ -163,16 +179,16 @@ def get_assignment(
except Exception as e:
if self.__is_graceful_mode:
logger.error("[Eppo SDK] Error getting assignment: " + str(e))
return None
return default
raise e

def get_assignment_variation(
def get_assignment_detail(
self,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aarsilv this function returns the FlagEvaluation object, which I currently consider to be the "detailed view of assignment".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this part of the "public" API then? Developers who want the full return object call this one instead of get__assignment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is my thought -- but open to feedback.

For example: should we have typed versions of the detailed version?

subject_key: str,
flag_key: str,
subject_attributes: Any,
expected_variation_type: Optional[str] = None,
) -> Optional[VariationDto]:
) -> Optional[FlagEvaluation]:
"""Maps a subject to a variation for a given experiment
Returns None if the subject is not part of the experiment sample.

Expand All @@ -183,114 +199,42 @@ def get_assignment_variation(
"""
validate_not_blank("subject_key", subject_key)
validate_not_blank("flag_key", flag_key)
experiment_config = self.__config_requestor.get_configuration(flag_key)
override = self._get_subject_variation_override(experiment_config, subject_key)
if override:
if expected_variation_type is not None:
variation_is_expected_type = VariationType.is_expected_type(
override, expected_variation_type
)
if not variation_is_expected_type:
return None
return override
flag = self.__config_requestor.get_configuration(flag_key)

if experiment_config is None or not experiment_config.enabled:
if flag is None or not flag.enabled:
logger.info(
"[Eppo SDK] No assigned variation. No active experiment or flag for key: "
+ flag_key
"[Eppo SDK] No assigned variation. No active flag for key: " + flag_key
)
return None

matched_rule = find_matching_rule(subject_attributes, experiment_config.rules)
if matched_rule is None:
logger.info(
"[Eppo SDK] No assigned variation. Subject attributes do not match targeting rules: {0}".format(
subject_attributes
)
)
return None
result = self.__evaluator.evaluate_flag(flag, subject_key, subject_attributes)

allocation = experiment_config.allocations[matched_rule.allocation_key]
if not self._is_in_experiment_sample(
subject_key,
flag_key,
experiment_config.subject_shards,
allocation.percent_exposure,
):
logger.info(
"[Eppo SDK] No assigned variation. Subject is not part of experiment sample population"
if expected_variation_type is not None and result.variation:
variation_is_expected_type = VariationType.is_expected_type(
result.variation, expected_variation_type
)
return None

shard = get_shard(
"assignment-{}-{}".format(subject_key, flag_key),
experiment_config.subject_shards,
)
assigned_variation = next(
(
variation
for variation in allocation.variations
if is_in_shard_range(shard, variation.shard_range)
),
None,
)

assigned_variation_value_to_log = None
if assigned_variation is not None:
assigned_variation_value_to_log = assigned_variation.value
if expected_variation_type is not None:
variation_is_expected_type = VariationType.is_expected_type(
assigned_variation, expected_variation_type
)
if not variation_is_expected_type:
return None
if not variation_is_expected_type:
return None

assignment_event = {
"allocation": matched_rule.allocation_key,
"experiment": f"{flag_key}-{matched_rule.allocation_key}",
**result.extra_logging,
"allocation": result.allocation_key,
"experiment": f"{flag_key}-{result.allocation_key}",
"featureFlag": flag_key,
"variation": assigned_variation_value_to_log,
"variation": result.variation.key,
"subject": subject_key,
"timestamp": datetime.datetime.utcnow().isoformat(),
"subjectAttributes": subject_attributes,
}
try:
self.__assignment_logger.log_assignment(assignment_event)
if result.do_log:
self.__assignment_logger.log_assignment(assignment_event)
except Exception as e:
logger.error("[Eppo SDK] Error logging assignment event: " + str(e))
return assigned_variation
return result

def _shutdown(self):
"""Stops all background processes used by the client
Do not use the client after calling this method.
"""
self.__poller.stop()

def _get_subject_variation_override(
self, experiment_config: Optional[ExperimentConfigurationDto], subject: str
) -> Optional[VariationDto]:
subject_hash = hashlib.md5(subject.encode("utf-8")).hexdigest()
if (
experiment_config is not None
and subject_hash in experiment_config.overrides
):
return VariationDto(
name="override",
value=experiment_config.overrides[subject_hash],
typed_value=experiment_config.typed_overrides[subject_hash],
shard_range=ShardRange(start=0, end=10000),
)
return None

def _is_in_experiment_sample(
self,
subject: str,
experiment_key: str,
subject_shards: int,
percent_exposure: float,
):
shard = get_shard(
"exposure-{}-{}".format(subject, experiment_key),
subject_shards,
)
return shard <= percent_exposure * subject_shards
2 changes: 1 addition & 1 deletion eppo_client/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from eppo_client.assignment_logger import AssignmentLogger
from eppo_client.base_model import SdkBaseModel
from eppo_client.models import SdkBaseModel
schmit marked this conversation as resolved.
Show resolved Hide resolved

from eppo_client.validation import validate_not_blank

Expand Down
38 changes: 6 additions & 32 deletions eppo_client/configuration_requestor.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,34 @@
import logging
from typing import Any, Dict, List, Optional, cast
from eppo_client.base_model import SdkBaseModel
from typing import Dict, Optional, cast
from eppo_client.configuration_store import ConfigurationStore
from eppo_client.http_client import HttpClient
from eppo_client.rules import Rule
from eppo_client.shard import ShardRange
from eppo_client.models import Flag

logger = logging.getLogger(__name__)


class VariationDto(SdkBaseModel):
name: str
value: str
typed_value: Any = None
shard_range: ShardRange


class AllocationDto(SdkBaseModel):
percent_exposure: float
variations: List[VariationDto]


class ExperimentConfigurationDto(SdkBaseModel):
subject_shards: int
enabled: bool
name: Optional[str] = None
overrides: Dict[str, str] = {}
typed_overrides: Dict[str, Any] = {}
rules: List[Rule] = []
allocations: Dict[str, AllocationDto]


RAC_ENDPOINT = "/randomized_assignment/v3/config"


class ExperimentConfigurationRequestor:
def __init__(
self,
http_client: HttpClient,
config_store: ConfigurationStore[ExperimentConfigurationDto],
config_store: ConfigurationStore[Flag],
):
self.__http_client = http_client
self.__config_store = config_store

def get_configuration(
self, experiment_key: str
) -> Optional[ExperimentConfigurationDto]:
def get_configuration(self, experiment_key: str) -> Optional[Flag]:
if self.__http_client.is_unauthorized():
raise ValueError("Unauthorized: please check your API key")
return self.__config_store.get_configuration(experiment_key)

def fetch_and_store_configurations(self) -> Dict[str, ExperimentConfigurationDto]:
def fetch_and_store_configurations(self) -> Dict[str, Flag]:
try:
configs = cast(dict, self.__http_client.get(RAC_ENDPOINT).get("flags", {}))
for exp_key, exp_config in configs.items():
configs[exp_key] = ExperimentConfigurationDto(**exp_config)
configs[exp_key] = Flag(**exp_config)
self.__config_store.set_configurations(configs)
return configs
except Exception as e:
schmit marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Loading
Loading