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

Commit 6840c33

Browse files
FF-3150 feat: allow passing initial_configuration (#70)
* FF-3150 feat: allow passing initial_configuration * FF-3150 feat: make configuration store track initialization * FF-3150 feat: allow disabling the poller * feat: re-export Configuration from eppo_client * FF-3150 docs: update documentation for initialization options * chore: bump version * FF-3150 feat: add a method to update configuration of the running client * check for None or 0 --------- Co-authored-by: Leo Romanovsky <[email protected]>
1 parent 12583d6 commit 6840c33

12 files changed

+134
-14
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ The `init` function accepts the following optional configuration arguments.
8181
| ------ | ----- | ----- | ----- |
8282
| **`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` |
8383
| **`is_graceful_mode`** | bool | When true, gracefully handles all exceptions within the assignment function and returns the default value. | `True` |
84-
| **`poll_interval_seconds`** | int | The interval in seconds at which the SDK polls for configuration updates. | `300` |
84+
| **`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` |
8585
| **`poll_jitter_seconds`** | int | The jitter in seconds to add to the poll interval. | `30` |
86+
| **`initial_configuration`** | Optional[Configuration] | If set, the client will use this configuration until it fetches a fresh one. | `None` |
8687

8788
## Assignment logger
8889

eppo_client/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from eppo_client.read_write_lock import ReadWriteLock
1111
from eppo_client.version import __version__
1212

13+
# re-export for convenience
14+
from eppo_client.configuration import Configuration # noqa: F401
15+
1316

1417
__client: Optional[EppoClient] = None
1518
__lock = ReadWriteLock()
@@ -32,6 +35,12 @@ def init(config: Config) -> EppoClient:
3235
http_client = HttpClient(base_url=config.base_url, sdk_params=sdk_params)
3336
flag_config_store: ConfigurationStore[Flag] = ConfigurationStore()
3437
bandit_config_store: ConfigurationStore[BanditData] = ConfigurationStore()
38+
39+
if config.initial_configuration:
40+
flag_config_store.set_configurations(
41+
config.initial_configuration._flags_configuration.flags
42+
)
43+
3544
config_requestor = ExperimentConfigurationRequestor(
3645
http_client=http_client,
3746
flag_config_store=flag_config_store,

eppo_client/client.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ActionContexts,
1212
)
1313
from eppo_client.models import Flag
14+
from eppo_client.configuration import Configuration
1415
from eppo_client.configuration_requestor import (
1516
ExperimentConfigurationRequestor,
1617
)
@@ -36,21 +37,29 @@ def __init__(
3637
config_requestor: ExperimentConfigurationRequestor,
3738
assignment_logger: AssignmentLogger,
3839
is_graceful_mode: bool = True,
39-
poll_interval_seconds: int = POLL_INTERVAL_SECONDS_DEFAULT,
40+
poll_interval_seconds: Optional[int] = POLL_INTERVAL_SECONDS_DEFAULT,
4041
poll_jitter_seconds: int = POLL_JITTER_SECONDS_DEFAULT,
4142
):
4243
self.__config_requestor = config_requestor
4344
self.__assignment_logger = assignment_logger
4445
self.__is_graceful_mode = is_graceful_mode
45-
self.__poller = Poller(
46-
interval_millis=poll_interval_seconds * 1000,
47-
jitter_millis=poll_jitter_seconds * 1000,
48-
callback=config_requestor.fetch_and_store_configurations,
49-
)
50-
self.__poller.start()
46+
47+
if poll_interval_seconds:
48+
self.__poller: Optional[Poller] = Poller(
49+
interval_millis=poll_interval_seconds * 1000,
50+
jitter_millis=poll_jitter_seconds * 1000,
51+
callback=config_requestor.fetch_and_store_configurations,
52+
)
53+
self.__poller.start()
54+
else:
55+
self.__poller = None
56+
5157
self.__evaluator = Evaluator(sharder=MD5Sharder())
5258
self.__bandit_evaluator = BanditEvaluator(sharder=MD5Sharder())
5359

60+
def set_configuration(self, configuration: Configuration):
61+
self.__config_requestor._set_configuration(configuration)
62+
5463
def get_string_assignment(
5564
self,
5665
flag_key: str,
@@ -434,7 +443,8 @@ def _shutdown(self):
434443
"""Stops all background processes used by the client
435444
Do not use the client after calling this method.
436445
"""
437-
self.__poller.stop()
446+
if self.__poller:
447+
self.__poller.stop()
438448

439449

440450
def check_type_match(

eppo_client/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from pydantic import Field, ConfigDict
2+
from typing import Optional
23

34
from eppo_client.assignment_logger import AssignmentLogger
45
from eppo_client.base_model import SdkBaseModel
6+
from eppo_client.configuration import Configuration
57
from eppo_client.validation import validate_not_blank
68
from eppo_client.constants import (
79
POLL_INTERVAL_SECONDS_DEFAULT,
@@ -19,8 +21,9 @@ class Config(SdkBaseModel):
1921
base_url: str = "https://fscdn.eppo.cloud/api"
2022
assignment_logger: AssignmentLogger = Field(exclude=True)
2123
is_graceful_mode: bool = True
22-
poll_interval_seconds: int = POLL_INTERVAL_SECONDS_DEFAULT
24+
poll_interval_seconds: Optional[int] = POLL_INTERVAL_SECONDS_DEFAULT
2325
poll_jitter_seconds: int = POLL_JITTER_SECONDS_DEFAULT
26+
initial_configuration: Optional[Configuration] = None
2427

2528
def _validate(self):
2629
validate_not_blank("api_key", self.api_key)

eppo_client/configuration.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from eppo_client.models import UfcResponse
2+
3+
4+
class Configuration:
5+
"""
6+
Client configuration fetched from the backend that dictates how to
7+
interpret feature flags.
8+
"""
9+
10+
def __init__(self, flags_configuration: str):
11+
self._flags_configuration = UfcResponse.model_validate_json(flags_configuration)

eppo_client/configuration_requestor.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from typing import Dict, Optional, cast
3+
from eppo_client.configuration import Configuration
34
from eppo_client.configuration_store import ConfigurationStore
45
from eppo_client.http_client import HttpClient
56
from eppo_client.models import BanditData, Flag
@@ -21,7 +22,6 @@ def __init__(
2122
self.__http_client = http_client
2223
self.__flag_config_store = flag_config_store
2324
self.__bandit_config_store = bandit_config_store
24-
self.__is_initialized = False
2525

2626
def get_configuration(self, flag_key: str) -> Optional[Flag]:
2727
if self.__http_client.is_unauthorized():
@@ -70,9 +70,13 @@ def fetch_and_store_configurations(self):
7070
if flag_data.get("bandits", {}):
7171
bandit_data = self.fetch_bandits()
7272
self.store_bandits(bandit_data)
73-
self.__is_initialized = True
7473
except Exception as e:
7574
logger.error("Error retrieving configurations: " + str(e))
7675

7776
def is_initialized(self):
78-
return self.__is_initialized
77+
return self.__flag_config_store.is_initialized()
78+
79+
def _set_configuration(self, configuration: Configuration):
80+
self.__flag_config_store.set_configurations(
81+
configuration._flags_configuration.flags
82+
)

eppo_client/configuration_store.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
class ConfigurationStore(Generic[T]):
99
def __init__(self):
10+
self.__is_initialized = False
1011
self.__cache: Dict[str, T] = {}
1112
self.__lock = ReadWriteLock()
1213

@@ -16,6 +17,7 @@ def get_configuration(self, key: str) -> Optional[T]:
1617

1718
def set_configurations(self, configs: Dict[str, T]):
1819
with self.__lock.writer():
20+
self.__is_initialized = True
1921
self.__cache = configs
2022

2123
def get_keys(self):
@@ -25,3 +27,7 @@ def get_keys(self):
2527
def get_configurations(self):
2628
with self.__lock.reader():
2729
return self.__cache
30+
31+
def is_initialized(self) -> bool:
32+
with self.__lock.reader():
33+
return self.__is_initialized

eppo_client/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class Flag(SdkBaseModel):
5555
total_shards: int = 10_000
5656

5757

58+
class UfcResponse(SdkBaseModel):
59+
flags: Dict[str, Flag]
60+
61+
5862
class BanditVariation(SdkBaseModel):
5963
key: str
6064
flag_key: str

eppo_client/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Note to developers: When ready to bump to 4.0, please change
22
# the `POLL_INTERVAL_SECONDS` constant in `eppo_client/constants.py`
33
# to 30 seconds to match the behavior of the other server SDKs.
4-
__version__ = "3.6.0"
4+
__version__ = "3.7.0"

test/client_no_poller_test.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import eppo_client
2+
from eppo_client.config import Config
3+
from eppo_client.assignment_logger import AssignmentLogger
4+
5+
6+
def test_no_poller():
7+
eppo_client.init(
8+
Config(
9+
api_key="blah",
10+
poll_interval_seconds=None,
11+
assignment_logger=AssignmentLogger(),
12+
)
13+
)

0 commit comments

Comments
 (0)