Repository: Unleash/unleash-client-python (python)

Issue:
Feat: add support for bootstrapping
Improve resilience by implementing the Bootstrap feature of Unleash. Feature write-up:
https://www.getunleash.io/blog/bootstrap-unleash-java-sdk-to-improve-the-resilience

Reference implementation: 
* Java SDK: https://github.com/Unleash/unleash-client-java/pull/131
* Node SDK: https://github.com/Unleash/unleash-client-node/pull/283/files


We need to bootstrap to be adapted to what makes sense in Pyhton. It should at least allow:
- manual passing of "json string of the full feature toggle configuration", 
- loading from file 
- loading from URL. 

If the user want to bootstrap from a URL we should probably try he actual Unleash API, and load the bootstrap URL as a fallback mechanism of the SDK fails to load it from the Unleash API. It will only load from bootstrap on the initial fetch. 

Pull request description:
Add bootstrapping
# Description

- Add support for bootstraping UnleashClient from dictionary, file, or url.
 
Fixes #203 

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

- [x] Unit tests
- [x] Spec Tests
- [x] Integration tests / Manual Tests

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d42f570..7669f86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
 
diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py
index 1523cc7..b006f9e 100644
--- a/UnleashClient/__init__.py
+++ b/UnleashClient/__init__.py
@@ -35,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,
@@ -89,6 +90,7 @@ class UnleashClient:
             METRIC_LAST_SENT_TIME: datetime.now(timezone.utc),
             ETAG: ''
         })
+        self.unleash_bootstrapped = self.cache.bootstrapped
 
         # Mappings
         default_strategy_mapping = {
@@ -110,6 +112,10 @@ class UnleashClient:
         # 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, fetch_toggles: bool = True) -> None:
         """
         Initializes client and starts communication with central unleash server(s).
@@ -248,7 +254,7 @@ class UnleashClient:
         # 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:
@@ -278,7 +284,7 @@ class UnleashClient:
         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:
diff --git a/UnleashClient/cache.py b/UnleashClient/cache.py
index 51c603c..9a9d565 100644
--- a/UnleashClient/cache.py
+++ b/UnleashClient/cache.py
@@ -1,10 +1,21 @@
 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):
+    """
+    Abstract base class for caches used for UnleashClient.
+
+    If implementing your own bootstrapping methods, you must set the `bootstrapped` attribute to True after configuration is set.
+    """
+    bootstrapped = False
+
     @abc.abstractmethod
     def set(self, key: str, value: Any):
         pass
@@ -27,9 +38,76 @@ class BaseCache(abc.ABC):
 
 
 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 resiliency 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)
+        self.bootstrapped = True
+
+    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()))
+            self.bootstrapped = True
+
+    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())
+        self.bootstrapped = True
+
     def set(self, key: str, value: Any):
         self._cache[key] = value
         self._cache.sync()
diff --git a/docs/basecache.rst b/docs/basecache.rst
index d1c3755..72da5f0 100644
--- a/docs/basecache.rst
+++ b/docs/basecache.rst
@@ -1,5 +1,5 @@
 ****************************************
-Cache
+BaseCache
 ****************************************
 
 .. autoclass:: UnleashClient.cache.BaseCache
diff --git a/docs/filecache.rst b/docs/filecache.rst
new file mode 100644
index 0000000..0f68da5
--- /dev/null
+++ b/docs/filecache.rst
@@ -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
diff --git a/docs/index.rst b/docs/index.rst
index 0b29533..f38936b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -31,6 +31,7 @@ Documentation for Unleash's Python client.  See the sidebar for more topics!
 
     unleashclient
     strategy
+    filecache
     basecache
 
 .. toctree::


Test patch:
diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py
index f1c9505..9ba9568 100644
--- a/tests/unit_tests/test_client.py
+++ b/tests/unit_tests/test_client.py
@@ -1,6 +1,7 @@
 import time
 import json
 import warnings
+from pathlib import Path
 
 import pytest
 import responses
@@ -424,3 +425,70 @@ 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
+    )
+    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
+    )
+    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
+    )
+    assert len(unleash_client.features) >= 4
+    assert unleash_client.is_enabled("testFlag")
diff --git a/tests/utilities/mocks/mock_bootstrap.json b/tests/utilities/mocks/mock_bootstrap.json
new file mode 100644
index 0000000..bcf024d
--- /dev/null
+++ b/tests/utilities/mocks/mock_bootstrap.json
@@ -0,0 +1,22 @@
+{
+    "version":1,
+    "features":[
+       {
+          "name":"ivan-project",
+          "type":"release",
+          "enabled": true,
+          "stale": false,
+          "strategies":[
+             {
+                "name":"default",
+                "parameters":{
+                   
+                }
+             }
+          ],
+          "variants":[
+             
+          ]
+       }
+    ]
+ }
\ No newline at end of file