Skip to content
This repository was archived by the owner on Nov 8, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions eppo_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -32,6 +35,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,
Expand Down
26 changes: 18 additions & 8 deletions eppo_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ActionContexts,
)
from eppo_client.models import Flag
from eppo_client.configuration import Configuration
from eppo_client.configuration_requestor import (
ExperimentConfigurationRequestor,
)
Expand All @@ -36,21 +37,29 @@ 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:
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())

def set_configuration(self, configuration: Configuration):
self.__config_requestor._set_configuration(configuration)

def get_string_assignment(
self,
flag_key: str,
Expand Down Expand Up @@ -434,7 +443,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(
Expand Down
5 changes: 4 additions & 1 deletion eppo_client/config.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,8 +21,9 @@ 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

def _validate(self):
validate_not_blank("api_key", self.api_key)
11 changes: 11 additions & 0 deletions eppo_client/configuration.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 7 additions & 3 deletions eppo_client/configuration_requestor.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,7 +22,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():
Expand Down Expand Up @@ -70,9 +70,13 @@ 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()

def _set_configuration(self, configuration: Configuration):
self.__flag_config_store.set_configurations(
configuration._flags_configuration.flags
)
6 changes: 6 additions & 0 deletions eppo_client/configuration_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

class ConfigurationStore(Generic[T]):
def __init__(self):
self.__is_initialized = False
self.__cache: Dict[str, T] = {}
self.__lock = ReadWriteLock()

Expand All @@ -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):
Expand All @@ -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
4 changes: 4 additions & 0 deletions eppo_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion eppo_client/version.py
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions test/client_no_poller_test.py
Original file line number Diff line number Diff line change
@@ -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(),
)
)
18 changes: 18 additions & 0 deletions test/configuration_test.py
Original file line number Diff line number Diff line change
@@ -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": []}')
41 changes: 41 additions & 0 deletions test/initial_configuration_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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()
Copy link
Member

Choose a reason for hiding this comment

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

nice - elegant.



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()