From 3ace12594b6c2d60b7533f0986c0328b1a8a47fb Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 4 Sep 2024 20:32:13 +0300 Subject: [PATCH 1/8] FF-3150 feat: allow passing initial_configuration --- eppo_client/__init__.py | 6 ++++++ eppo_client/config.py | 3 +++ eppo_client/configuration.py | 11 +++++++++++ eppo_client/models.py | 4 ++++ test/configuration_test.py | 18 ++++++++++++++++++ test/initial_configuration_test.py | 27 +++++++++++++++++++++++++++ 6 files changed, 69 insertions(+) create mode 100644 eppo_client/configuration.py create mode 100644 test/configuration_test.py create mode 100644 test/initial_configuration_test.py diff --git a/eppo_client/__init__.py b/eppo_client/__init__.py index 427ed12..f087a96 100644 --- a/eppo_client/__init__.py +++ b/eppo_client/__init__.py @@ -32,6 +32,12 @@ def init(config: Config) -> EppoClient: http_client = HttpClient(base_url=config.base_url, sdk_params=sdk_params) flag_config_store: ConfigurationStore[Flag] = ConfigurationStore() bandit_config_store: ConfigurationStore[BanditData] = ConfigurationStore() + + if config.initial_configuration: + flag_config_store.set_configurations( + config.initial_configuration._flags_configuration.flags + ) + config_requestor = ExperimentConfigurationRequestor( http_client=http_client, flag_config_store=flag_config_store, diff --git a/eppo_client/config.py b/eppo_client/config.py index 98ea73e..b2267fc 100644 --- a/eppo_client/config.py +++ b/eppo_client/config.py @@ -1,7 +1,9 @@ from pydantic import Field, ConfigDict +from typing import Optional from eppo_client.assignment_logger import AssignmentLogger from eppo_client.base_model import SdkBaseModel +from eppo_client.configuration import Configuration from eppo_client.validation import validate_not_blank from eppo_client.constants import ( POLL_INTERVAL_SECONDS_DEFAULT, @@ -21,6 +23,7 @@ class Config(SdkBaseModel): is_graceful_mode: bool = True poll_interval_seconds: int = POLL_INTERVAL_SECONDS_DEFAULT poll_jitter_seconds: int = POLL_JITTER_SECONDS_DEFAULT + initial_configuration: Optional[Configuration] = None def _validate(self): validate_not_blank("api_key", self.api_key) diff --git a/eppo_client/configuration.py b/eppo_client/configuration.py new file mode 100644 index 0000000..4df901f --- /dev/null +++ b/eppo_client/configuration.py @@ -0,0 +1,11 @@ +from eppo_client.models import UfcResponse + + +class Configuration: + """ + Client configuration fetched from the backend that dictates how to + interpret feature flags. + """ + + def __init__(self, flags_configuration: str): + self._flags_configuration = UfcResponse.model_validate_json(flags_configuration) diff --git a/eppo_client/models.py b/eppo_client/models.py index 54add2f..cd74dbc 100644 --- a/eppo_client/models.py +++ b/eppo_client/models.py @@ -55,6 +55,10 @@ class Flag(SdkBaseModel): total_shards: int = 10_000 +class UfcResponse(SdkBaseModel): + flags: Dict[str, Flag] + + class BanditVariation(SdkBaseModel): key: str flag_key: str diff --git a/test/configuration_test.py b/test/configuration_test.py new file mode 100644 index 0000000..1de83e4 --- /dev/null +++ b/test/configuration_test.py @@ -0,0 +1,18 @@ +import pytest +import pydantic + +from eppo_client.configuration import Configuration + + +def test_init_valid(): + Configuration(flags_configuration='{"flags": {}}') + + +def test_init_invalid_json(): + with pytest.raises(pydantic.ValidationError): + Configuration(flags_configuration="") + + +def test_init_invalid_format(): + with pytest.raises(pydantic.ValidationError): + Configuration(flags_configuration='{"flags": []}') diff --git a/test/initial_configuration_test.py b/test/initial_configuration_test.py new file mode 100644 index 0000000..5f54c01 --- /dev/null +++ b/test/initial_configuration_test.py @@ -0,0 +1,27 @@ +import eppo_client +from eppo_client.config import Config +from eppo_client.configuration import Configuration +from eppo_client.assignment_logger import AssignmentLogger + + +def test_without_initial_configuration(): + client = eppo_client.init( + Config( + api_key="test", + base_url="http://localhost:8378/api", + assignment_logger=AssignmentLogger(), + ) + ) + assert not client.is_initialized() + + +def test_with_initial_configuration(): + client = eppo_client.init( + Config( + api_key="test", + base_url="http://localhost:8378/api", + assignment_logger=AssignmentLogger(), + initial_configuration=Configuration(flags_configuration='{"flags":{}}'), + ) + ) + assert client.is_initialized() From 821ea364f15662adc47a1979e7a7777441bbc784 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 5 Sep 2024 13:23:28 +0300 Subject: [PATCH 2/8] FF-3150 feat: make configuration store track initialization --- eppo_client/configuration_requestor.py | 4 +--- eppo_client/configuration_store.py | 6 ++++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/eppo_client/configuration_requestor.py b/eppo_client/configuration_requestor.py index a0e5d96..1e42dc3 100644 --- a/eppo_client/configuration_requestor.py +++ b/eppo_client/configuration_requestor.py @@ -21,7 +21,6 @@ def __init__( self.__http_client = http_client self.__flag_config_store = flag_config_store self.__bandit_config_store = bandit_config_store - self.__is_initialized = False def get_configuration(self, flag_key: str) -> Optional[Flag]: if self.__http_client.is_unauthorized(): @@ -70,9 +69,8 @@ def fetch_and_store_configurations(self): if flag_data.get("bandits", {}): bandit_data = self.fetch_bandits() self.store_bandits(bandit_data) - self.__is_initialized = True except Exception as e: logger.error("Error retrieving configurations: " + str(e)) def is_initialized(self): - return self.__is_initialized + return self.__flag_config_store.is_initialized() diff --git a/eppo_client/configuration_store.py b/eppo_client/configuration_store.py index 475e884..3301f60 100644 --- a/eppo_client/configuration_store.py +++ b/eppo_client/configuration_store.py @@ -7,6 +7,7 @@ class ConfigurationStore(Generic[T]): def __init__(self): + self.__is_initialized = False self.__cache: Dict[str, T] = {} self.__lock = ReadWriteLock() @@ -16,6 +17,7 @@ def get_configuration(self, key: str) -> Optional[T]: def set_configurations(self, configs: Dict[str, T]): with self.__lock.writer(): + self.__is_initialized = True self.__cache = configs def get_keys(self): @@ -25,3 +27,7 @@ def get_keys(self): def get_configurations(self): with self.__lock.reader(): return self.__cache + + def is_initialized(self) -> bool: + with self.__lock.reader(): + return self.__is_initialized From 4dec34fe5ad0aefc02c76af05401bf209c3e5077 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 5 Sep 2024 13:31:08 +0300 Subject: [PATCH 3/8] FF-3150 feat: allow disabling the poller --- eppo_client/client.py | 22 ++++++++++++++-------- eppo_client/config.py | 2 +- test/client_no_poller_test.py | 13 +++++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 test/client_no_poller_test.py diff --git a/eppo_client/client.py b/eppo_client/client.py index eb31898..b02c178 100644 --- a/eppo_client/client.py +++ b/eppo_client/client.py @@ -36,18 +36,23 @@ def __init__( config_requestor: ExperimentConfigurationRequestor, assignment_logger: AssignmentLogger, is_graceful_mode: bool = True, - poll_interval_seconds: int = POLL_INTERVAL_SECONDS_DEFAULT, + poll_interval_seconds: Optional[int] = POLL_INTERVAL_SECONDS_DEFAULT, poll_jitter_seconds: int = POLL_JITTER_SECONDS_DEFAULT, ): self.__config_requestor = config_requestor self.__assignment_logger = assignment_logger self.__is_graceful_mode = is_graceful_mode - self.__poller = Poller( - interval_millis=poll_interval_seconds * 1000, - jitter_millis=poll_jitter_seconds * 1000, - callback=config_requestor.fetch_and_store_configurations, - ) - self.__poller.start() + + if poll_interval_seconds is not None: + self.__poller: Optional[Poller] = Poller( + interval_millis=poll_interval_seconds * 1000, + jitter_millis=poll_jitter_seconds * 1000, + callback=config_requestor.fetch_and_store_configurations, + ) + self.__poller.start() + else: + self.__poller = None + self.__evaluator = Evaluator(sharder=MD5Sharder()) self.__bandit_evaluator = BanditEvaluator(sharder=MD5Sharder()) @@ -434,7 +439,8 @@ def _shutdown(self): """Stops all background processes used by the client Do not use the client after calling this method. """ - self.__poller.stop() + if self.__poller: + self.__poller.stop() def check_type_match( diff --git a/eppo_client/config.py b/eppo_client/config.py index b2267fc..fbf3de4 100644 --- a/eppo_client/config.py +++ b/eppo_client/config.py @@ -21,7 +21,7 @@ class Config(SdkBaseModel): base_url: str = "https://fscdn.eppo.cloud/api" assignment_logger: AssignmentLogger = Field(exclude=True) is_graceful_mode: bool = True - poll_interval_seconds: int = POLL_INTERVAL_SECONDS_DEFAULT + poll_interval_seconds: Optional[int] = POLL_INTERVAL_SECONDS_DEFAULT poll_jitter_seconds: int = POLL_JITTER_SECONDS_DEFAULT initial_configuration: Optional[Configuration] = None diff --git a/test/client_no_poller_test.py b/test/client_no_poller_test.py new file mode 100644 index 0000000..c12ecf8 --- /dev/null +++ b/test/client_no_poller_test.py @@ -0,0 +1,13 @@ +import eppo_client +from eppo_client.config import Config +from eppo_client.assignment_logger import AssignmentLogger + + +def test_no_poller(): + eppo_client.init( + Config( + api_key="blah", + poll_interval_seconds=None, + assignment_logger=AssignmentLogger(), + ) + ) From f9f84e5555f2d7851995cbfcc37aaab0b8353c9b Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 5 Sep 2024 13:42:25 +0300 Subject: [PATCH 4/8] feat: re-export Configuration from eppo_client --- eppo_client/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eppo_client/__init__.py b/eppo_client/__init__.py index f087a96..647d353 100644 --- a/eppo_client/__init__.py +++ b/eppo_client/__init__.py @@ -10,6 +10,9 @@ from eppo_client.read_write_lock import ReadWriteLock from eppo_client.version import __version__ +# re-export for convenience +from eppo_client.configuration import Configuration # noqa: F401 + __client: Optional[EppoClient] = None __lock = ReadWriteLock() From 18777e1048d0bfdedf60a5800168c2dd486b58db Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 5 Sep 2024 13:44:45 +0300 Subject: [PATCH 5/8] FF-3150 docs: update documentation for initialization options --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 79e3a0d..d69bd40 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ The `init` function accepts the following optional configuration arguments. | ------ | ----- | ----- | ----- | | **`assignment_logger`** | [AssignmentLogger](https://github.com/Eppo-exp/python-sdk/blob/ebc1a0b781769fe9d2e2be6fc81779eb8685a6c7/eppo_client/assignment_logger.py#L6-L10) | A callback that sends each assignment to your data warehouse. Required only for experiment analysis. See [example](#assignment-logger) below. | `None` | | **`is_graceful_mode`** | bool | When true, gracefully handles all exceptions within the assignment function and returns the default value. | `True` | -| **`poll_interval_seconds`** | int | The interval in seconds at which the SDK polls for configuration updates. | `300` | +| **`poll_interval_seconds`** | Optional[int] | The interval in seconds at which the SDK polls for configuration updates. If set to `None`, polling is disabled. | `300` | | **`poll_jitter_seconds`** | int | The jitter in seconds to add to the poll interval. | `30` | +| **`initial_configuration`** | Optional[Configuration] | If set, the client will use this configuration until it fetches a fresh one. | `None` | ## Assignment logger From f29fe7acef30604dd71beb8c1c151f92fefa9701 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 5 Sep 2024 13:56:54 +0300 Subject: [PATCH 6/8] chore: bump version --- eppo_client/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eppo_client/version.py b/eppo_client/version.py index 415a809..01e2117 100644 --- a/eppo_client/version.py +++ b/eppo_client/version.py @@ -1,4 +1,4 @@ # Note to developers: When ready to bump to 4.0, please change # the `POLL_INTERVAL_SECONDS` constant in `eppo_client/constants.py` # to 30 seconds to match the behavior of the other server SDKs. -__version__ = "3.6.0" +__version__ = "3.7.0" From 4fd588ff55375bd07eab95c599ee677711686188 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 5 Sep 2024 16:23:02 +0300 Subject: [PATCH 7/8] FF-3150 feat: add a method to update configuration of the running client --- eppo_client/client.py | 4 ++++ eppo_client/configuration_requestor.py | 6 ++++++ test/initial_configuration_test.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/eppo_client/client.py b/eppo_client/client.py index b02c178..75dd94f 100644 --- a/eppo_client/client.py +++ b/eppo_client/client.py @@ -11,6 +11,7 @@ ActionContexts, ) from eppo_client.models import Flag +from eppo_client.configuration import Configuration from eppo_client.configuration_requestor import ( ExperimentConfigurationRequestor, ) @@ -56,6 +57,9 @@ def __init__( self.__evaluator = Evaluator(sharder=MD5Sharder()) self.__bandit_evaluator = BanditEvaluator(sharder=MD5Sharder()) + def set_configuration(self, configuration: Configuration): + self.__config_requestor._set_configuration(configuration) + def get_string_assignment( self, flag_key: str, diff --git a/eppo_client/configuration_requestor.py b/eppo_client/configuration_requestor.py index 1e42dc3..997aaf9 100644 --- a/eppo_client/configuration_requestor.py +++ b/eppo_client/configuration_requestor.py @@ -1,5 +1,6 @@ import logging from typing import Dict, Optional, cast +from eppo_client.configuration import Configuration from eppo_client.configuration_store import ConfigurationStore from eppo_client.http_client import HttpClient from eppo_client.models import BanditData, Flag @@ -74,3 +75,8 @@ def fetch_and_store_configurations(self): def is_initialized(self): return self.__flag_config_store.is_initialized() + + def _set_configuration(self, configuration: Configuration): + self.__flag_config_store.set_configurations( + configuration._flags_configuration.flags + ) diff --git a/test/initial_configuration_test.py b/test/initial_configuration_test.py index 5f54c01..e381058 100644 --- a/test/initial_configuration_test.py +++ b/test/initial_configuration_test.py @@ -25,3 +25,17 @@ def test_with_initial_configuration(): ) ) assert client.is_initialized() + + +def test_update_configuration(): + client = eppo_client.init( + Config( + api_key="test", + poll_interval_seconds=None, + assignment_logger=AssignmentLogger(), + ) + ) + + client.set_configuration(Configuration(flags_configuration='{"flags":{}}')) + + assert client.is_initialized() From c4a7cc2f71f69ac70f5041f231489ac5dc5f5bb6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 5 Sep 2024 14:14:09 -0700 Subject: [PATCH 8/8] check for None or 0 --- eppo_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eppo_client/client.py b/eppo_client/client.py index 75dd94f..7b5f3fa 100644 --- a/eppo_client/client.py +++ b/eppo_client/client.py @@ -44,7 +44,7 @@ def __init__( self.__assignment_logger = assignment_logger self.__is_graceful_mode = is_graceful_mode - if poll_interval_seconds is not None: + if poll_interval_seconds: self.__poller: Optional[Poller] = Poller( interval_millis=poll_interval_seconds * 1000, jitter_millis=poll_jitter_seconds * 1000,