Skip to content

Commit

Permalink
Merge 8663d38 into 3a756cc
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanklee86 committed Apr 23, 2022
2 parents 3a756cc + 8663d38 commit db1684e
Show file tree
Hide file tree
Showing 8 changed files with 198 additions and 4 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v5.2.0
* (Minor) Add support for bootstrapping UnleashClient with an initial configuration.

## v5.1.2
* (Bugfix) Clarify logging if Unleash server doesn't return feature provisioning (i.e. HTTP 304).

Expand Down
14 changes: 11 additions & 3 deletions UnleashClient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from UnleashClient.strategies import ApplicationHostname, Default, GradualRolloutRandom, \
GradualRolloutSessionId, GradualRolloutUserId, UserWithId, RemoteAddress, FlexibleRollout
from UnleashClient.constants import METRIC_LAST_SENT_TIME, DISABLED_VARIATION, ETAG
from UnleashClient.loader import load_features
from .utils import LOGGER
from .deprecation_warnings import strategy_v2xx_deprecation_check
from .cache import BaseCache, FileCache
Expand All @@ -34,6 +35,7 @@ class UnleashClient:
:param cache_directory: Location of the cache directory. When unset, FCache will determine the location.
:param verbose_log_level: Numerical log level (https://docs.python.org/3/library/logging.html#logging-levels) for cases where checking a feature flag fails.
:param cache: Custom cache implementation that extends UnleashClient.cache.BaseCache. When unset, UnleashClient will use Fcache.
:param bootstraped: Whether cache has been boostrapped (i.e. pre-seeded) with Unleash configuration. When true, UnleashClient will use initial configuration until the client is initialized. See FileCache object for more information about bootstrapping.
"""
def __init__(self,
url: str,
Expand All @@ -52,7 +54,8 @@ def __init__(self,
cache_directory: Optional[str] = None,
project_name: str = None,
verbose_log_level: int = 30,
cache: Optional[BaseCache] = None) -> None:
cache: Optional[BaseCache] = None,
bootstrapped: Optional[bool] = False) -> None:
custom_headers = custom_headers or {}
custom_options = custom_options or {}
custom_strategies = custom_strategies or {}
Expand All @@ -76,6 +79,7 @@ def __init__(self,
}
self.unleash_project_name = project_name
self.unleash_verbose_log_level = verbose_log_level
self.unleash_bootstrapped = bootstrapped

# Class objects
self.features: dict = {}
Expand Down Expand Up @@ -109,6 +113,10 @@ def __init__(self,
# Client status
self.is_initialized = False

# Bootstrapping
if self.unleash_bootstrapped:
load_features(cache=self.cache, feature_toggles=self.features, strategy_mapping=self.strategy_mapping)

def initialize_client(self) -> None:
"""
Initializes client and starts communication with central unleash server(s).
Expand Down Expand Up @@ -234,7 +242,7 @@ def is_enabled(self,
# Update context with static values
context.update(self.unleash_static_context)

if self.is_initialized:
if self.unleash_bootstrapped or self.is_initialized:
try:
return self.features[feature_name].is_enabled(context)
except Exception as excep:
Expand Down Expand Up @@ -264,7 +272,7 @@ def get_variant(self,
context = context or {}
context.update(self.unleash_static_context)

if self.is_initialized:
if self.unleash_bootstrapped or self.is_initialized:
try:
return self.features[feature_name].get_variant(context)
except Exception as excep:
Expand Down
68 changes: 68 additions & 0 deletions UnleashClient/cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import abc
import json
from pathlib import Path
from typing import Any, Optional

import requests
from fcache.cache import FileCache as _FileCache
from UnleashClient.constants import FEATURES_URL


class BaseCache(abc.ABC):
Expand All @@ -27,9 +31,73 @@ def destroy(self):


class FileCache(BaseCache):
"""
The default cache for UnleashClient. Uses `fcache <https://pypi.org/project/fcache/>`_ behind the scenes.
You can boostrap the FileCache with initial configuration to improve resiliancy on startup. To do so:
- Create a new FileCache instance.
- Bootstrap the FileCache.
- Pass your FileCache instance to UnleashClient at initialization along with `boostrap=true`.
You can bootstrap from a dictionary, a json file, or from a URL. In all cases, configuration should match the Unleash `/api/client/features <https://docs.getunleash.io/api/client/features>`_ endpoint.
Example:
.. code-block:: python
from pathlib import Path
from UnleashClient.cache import FileCache
from UnleashClient import UnleashClient
my_cache = FileCache("HAMSTER_API")
my_cache.bootstrap_from_file(Path("/path/to/boostrap.json"))
unleash_client = UnleashClient(
"https://my.unleash.server.com",
"HAMSTER_API",
cache=cache,
bootstrapped=True
)
:param name: Name of cache.
:param directory: Location to create cache. If empty, will use filecache default.
"""
def __init__(self, name: str, directory: Optional[str] = None):
self._cache = _FileCache(name, app_cache_dir=directory)

def bootstrap_from_dict(self, initial_config: dict) -> None:
"""
Loads initial Unleash configuration from a dictionary.
Note: Pre-seeded configuration will only be used if UnleashClient is initialized with `bootstrap=true`.
:param initial_config: Dictionary that contains initial configuration.
"""
self.set(FEATURES_URL, initial_config)

def bootstrap_from_file(self, initial_config_file: Path) -> None:
"""
Loads initial Unleash configuration from a file.
Note: Pre-seeded configuration will only be used if UnleashClient is initialized with `bootstrap=true`.
:param initial_configuration_file: Path to document containing initial configuration. Must be JSON.
"""
with open(initial_config_file, "r", encoding="utf8") as bootstrap_file:
self.set(FEATURES_URL, json.loads(bootstrap_file.read()))

def bootstrap_from_url(self, initial_config_url: str, headers: Optional[dict] = None) -> None:
"""
Loads initial Unleash configuration from a url.
Note: Pre-seeded configuration will only be used if UnleashClient is initialized with `bootstrap=true`.
:param initial_configuration_url: Url that returns document containing initial configuration. Must return JSON.
:param headers: Headers to use when GETing the initial configuration URL.
"""
response = requests.get(initial_config_url, headers=headers)
self.set(FEATURES_URL, response.json())

def set(self, key: str, value: Any):
self._cache[key] = value
self._cache.sync()
Expand Down
2 changes: 1 addition & 1 deletion docs/basecache.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
****************************************
Cache
BaseCache
****************************************

.. autoclass:: UnleashClient.cache.BaseCache
Expand Down
21 changes: 21 additions & 0 deletions docs/filecache.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
****************************************
FileCache
****************************************

.. autoclass:: UnleashClient.cache.FileCache

.. automethod:: bootstrap_from_dict

.. automethod:: bootstrap_from_file

.. automethod:: bootstrap_from_url

.. automethod:: set

.. automethod:: mset

.. automethod:: get

.. automethod:: exists

.. automethod:: destroy
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Documentation for Unleash's Python client. See the sidebar for more topics!

unleashclient
strategy
filecache
basecache

.. toctree::
Expand Down
71 changes: 71 additions & 0 deletions tests/unit_tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import time
import json
import warnings
from pathlib import Path

import pytest
import responses
Expand Down Expand Up @@ -424,3 +425,73 @@ def test_uc_multiple_initializations(unleash_client):

assert len(w) == 1
assert "initialize" in str(w[0].message)


@responses.activate
def test_uc_cache_bootstrap_dict(cache):
# Set up API
responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200, headers={'etag': ETAG_VALUE})
responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)

# Set up cache
cache.bootstrap_from_dict(initial_config=MOCK_FEATURE_RESPONSE_PROJECT)

# Check bootstrapping
unleash_client = UnleashClient(
URL,
APP_NAME,
refresh_interval=REFRESH_INTERVAL,
metrics_interval=METRICS_INTERVAL,
cache=cache,
bootstrapped=True
)
assert len(unleash_client.features) == 1
assert unleash_client.is_enabled("ivan-project")

# Create Unleash client and check initial load
unleash_client.initialize_client()
time.sleep(1)
assert unleash_client.is_initialized
assert len(unleash_client.features) >= 4
assert unleash_client.is_enabled("testFlag")


@responses.activate
def test_uc_cache_bootstrap_file(cache):
# Set up cache
test_file = Path(Path(__file__).parent.resolve(), '..', 'utilities', 'mocks', 'mock_bootstrap.json')
cache.bootstrap_from_file(initial_config_file=test_file)

# Check bootstrapping
unleash_client = UnleashClient(
URL,
APP_NAME,
refresh_interval=REFRESH_INTERVAL,
metrics_interval=METRICS_INTERVAL,
cache=cache,
bootstrapped=True
)
assert len(unleash_client.features) >= 1
assert unleash_client.is_enabled("ivan-project")


@responses.activate
def test_uc_cache_bootstrap_url(cache):
# Set up API
responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200, headers={'etag': ETAG_VALUE})

# Set up cache
cache.bootstrap_from_url(initial_config_url=URL + FEATURES_URL)

# Check bootstrapping
unleash_client = UnleashClient(
URL,
APP_NAME,
refresh_interval=REFRESH_INTERVAL,
metrics_interval=METRICS_INTERVAL,
cache=cache,
bootstrapped=True
)
assert len(unleash_client.features) >= 4
assert unleash_client.is_enabled("testFlag")
22 changes: 22 additions & 0 deletions tests/utilities/mocks/mock_bootstrap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"version":1,
"features":[
{
"name":"ivan-project",
"type":"release",
"enabled": true,
"stale": false,
"strategies":[
{
"name":"default",
"parameters":{

}
}
],
"variants":[

]
}
]
}

0 comments on commit db1684e

Please sign in to comment.