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

Issue:
Feature request: implement a subscriber API to align with other SDK offerings
**Is your feature request related to a problem? Please describe.**

We are trying to log toggle status changes so that we know when exactly those changes have been applied to the application, but we have no way to subscribe to those events.

**Describe the solution you'd like**
A solution [similar to the JDK's SDK](https://docs.getunleash.io/reference/sdks/java#subscriber-api) which allows us to create subscribers to different events so that our applications can act upon those events.

**Describe alternatives you've considered**
For now we will probably manually keep track of the status of the toggles and wrap the `is_enabled` call so that when we notice a status change we can print a log. Either that or try to override the `fetch_and_load_features` function somehow.

**Additional context**
N/A


Pull request description:
feat: events
Adds in events for when features are fetched and when the SDK becomes ready.

This brings us more inline with the other SDKs which have these events at minimum.

Closes #356 

Patch:
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 0000000..6bd255d
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,67 @@
+## Developing
+
+For development, you'll need to setup the environment to run the tests. This repository is using
+tox to run the test suite to test against multiple versions of Python. Running the tests is as simple as running this command in the makefile:
+
+```
+tox -e py311
+```
+
+This command will take care of downloading the client specifications and putting them in the correct place in the repository, and install all the dependencies you need.
+
+However, there are some caveats to this method. There is no easy way to run a single test, and running the entire test suite can be slow.
+
+### Manual setup
+
+First, make sure you have pip or pip3 installed.
+
+Then set up your virtual environment:
+
+Linux & Mac:
+
+```
+python3 -m venv venv
+source venv/bin/activate
+```
+
+Windows + cmd:
+
+```
+python -m venv venv
+venv\Scripts\activate.bat
+```
+
+Powershell:
+
+```
+python -m venv venv
+venv\Scripts\activate.bat
+```
+
+Once you've done your setup, run:
+```
+pip install -r requirements.txt
+```
+
+Run the get-spec script to download the client specifications tests:
+```
+./scripts/get-spec.sh
+```
+
+Now you can run the tests by running `pytest` in the root directory.
+
+In order to run a single test, run the following command:
+
+```
+pytest testfile.py::function_name
+
+# example: pytest tests/unit_tests/test_client.py::test_consistent_results
+```
+
+### Linting
+
+In order to lint all the files you can run the following command:
+
+```
+make fmt
+```
diff --git a/README.md b/README.md
index 13bae0f..a28ae90 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,99 @@
-# unleash-client-python
+# Unleash Client SDK for Python
 
 ![](https://github.com/unleash/unleash-client-python/workflows/CI/badge.svg?branch=main) [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash-client-python/badge.svg?branch=main)](https://coveralls.io/github/Unleash/unleash-client-python?branch=main) [![PyPI version](https://badge.fury.io/py/UnleashClient.svg)](https://badge.fury.io/py/UnleashClient) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/UnleashClient.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 
+Unleash is a private, secure, and scalable [feature management platform](https://www.getunleash.io/) built to reduce the risk of releasing new features and accelerate software development. This server-side Python SDK is designed to help you integrate with Unleash and evaluate feature flags inside your application.
 
-This is the Python client for [Unleash](https://github.com/unleash/unleash).  It implements [Client Specifications 1.0](https://docs.getunleash.io/client-specification) and checks compliance based on spec in [unleash/client-specifications](https://github.com/Unleash/client-specification)
+You can use this client with [Unleash Enterprise](https://www.getunleash.io/pricing?utm_source=readme&utm_medium=python) or [Unleash Open Source](https://github.com/Unleash/unleash).
 
 >  **Migrating to v6**
 >
 > If you use custom strategies or access the `features` property on the Unleash Client, read the complete [migration guide](./v6_MIGRATION_GUIDE.md) before upgrading to v6.
 
 
-What it supports:
-* Default activation strategies using 32-bit [Murmurhash3](https://en.wikipedia.org/wiki/MurmurHash)
-* Custom strategies
-* Full client lifecycle:
-    * Client registers with Unleash server
-    * Client periodically fetches feature toggles and stores to on-disk cache
-    * Client periodically sends metrics to Unleash Server
-* Tested on Linux (Ubuntu), OSX, and Windows
+## Getting Started
 
-Check out the [project documentation](https://unleash.github.io/unleash-client-python/) and the [changelog](https://docs.getunleash.io/unleash-client-python/changelog.html).
-
-## Installation
-
-Check out the package on [Pypi](https://pypi.org/project/UnleashClient/)!
+### Install the Unleash Client in your project
 
 ```bash
 pip install UnleashClient
 ```
 
-## Usage
-
 ### Initialization
 
+You must initialize the SDK before you use it. Note that until the SDK has synchronized with the API, all features will evaluate to `false` unless
+you have a [bootstrapped configuration](#bootstrap) or you use [fallbacks](#fallback-function).
+
 ```python
 from UnleashClient import UnleashClient
 
 client = UnleashClient(
-    url="https://unleash.herokuapp.com",
+    url="https:<YOUR-API-URL>",
     app_name="my-python-app",
     custom_headers={'Authorization': '<API token>'})
 
 client.initialize_client()
 ```
 
-For more information about configuring `UnleashClient`, check out the [project reference docs](https://docs.getunleash.io/unleash-client-python/unleashclient.html)!
+### Check features
 
-### Checking if a feature is enabled
+Once the SDK is initialized, you can evaluate toggles using the `is_enabled` or `get_variant` methods.
 
-A check of a simple toggle:
 ```python
-client.is_enabled("my_toggle")
+enabled = client.is_enabled("my_toggle")
+print(enabled)
+> True
+
+variant = client.get_variant("variant_toggle")
+print(variant)
+> {
+>    "name": "variant1",
+>    "payload": {
+>        "type": "string",
+>        "value": "val1"
+>        },
+>    "enabled": True
+> }
+
 ```
 
-To supply application context, use the second positional argument:
+### Shutdown
+
+If your program no longer needs the SDK, you can call `destroy()`, which shuts down the SDK and flushes any pending metrics to Unleash.
 
 ```python
-app_context = {"userId": "test@email.com"}
+client.destroy()
+```
+
+## Usage
+
+### Context
+
+Both the `is_enabled` and `get_variant` functions support [Unleash contexts](https://docs.getunleash.io/reference/unleash-context) as the second parameter.
+
+``` python
+app_context = {
+    "userId": "test@email.com",
+    "sessionId": "55845",
+    "properties": {
+        "custom-property": "some-value"
+    }
+}
+
 client.is_enabled("user_id_toggle", app_context)
+client.get_variant("variant_toggle", app_context)
 ```
 
-#### Fallback function and default values
+The context values can be any type that has a `__str__` implementation. Types that are explicitly supported are:
+
+- Numerics
+- Strings
+- Dates
+- UUIDs
+
+**Note**: Gradual rollout strategies require you to pass either a `userId` or a `sessionId` for [stickiness](https://docs.getunleash.io/reference/stickiness) to work correctly.
+
+### Fallback function
 
 You can specify a fallback function for cases where the client doesn't recognize the toggle by using the `fallback_function` keyword argument:
 
@@ -71,103 +104,267 @@ def custom_fallback(feature_name: str, context: dict) -> bool:
 client.is_enabled("my_toggle", fallback_function=custom_fallback)
 ```
 
-You can also use the `fallback_function` argument to replace the obsolete `default_value` keyword argument by using a lambda that ignores its inputs. Whatever the lambda returns will be used as the default value.
+The fallback function **must** accept the feature name and context as positional arguments in that order.
+
+The client will evaluate the fallback function if the feature flag is not found or an exception occurs when calling the `is_enabled()` method.
+
+### Configuration options
+
+The UnleashClient constructor supports the following configuration options:
+
+| Parameter 	            | Description	                                                                                                                                    | Default                   |
+| ------------ 	            | ------------                      	                                                                                                            | ------------              |
+| url 	                    | URL of your Unleash server. E.g. `https://app.unleash-hosted.com/demo/api/` Required.                                                             | None                      |
+| app_name 	                | Name of the application using the client. Required.	                                                                                            | None                      |
+| environment               | Logical environment name (deprecated).	                                                                                                        | "default"                 |
+| instance_id               | Unique identifier for this client instance.	                                                                                                    | "unleash-client-python"   |
+| refresh_interval  	    | How often to fetch feature toggles (seconds).	                                                                                                    | 15                        |
+| refresh_jitter            | Jitter to add to refresh interval (seconds).	                                                                                                    | None                      |
+| metrics_interval  	    | How often to send metrics to Unleash (seconds).	                                                                                                | 60                        |
+| metrics_jitter            | Jitter to add to metrics interval (seconds).	                                                                                                    | None                      |
+| disable_metrics           | Disable sending usage metrics.	                                                                                                                | False                     |
+| disable_registration      | Disable client registration.	                                                                                                                    | False                     |
+| custom_headers            | Additional HTTP headers (e.g. Authorization).	                                                                                                    | None                      |
+| custom_options            | Extra options for [HTTP requests](https://requests.readthedocs.io/en/latest/api/#main-interface).                                                 | None                      |
+| request_timeout           | HTTP request timeout (seconds).	                                                                                                                | 30                        |
+| request_retries           | HTTP request retry count.	                                                                                                                        | 3                         |
+| custom_strategies 	    | Dict of {name: strategy} for custom activation strategies. See [custom strategies](#custom-strategies) for more information.                      | None                      |
+| cache_directory 	        | Location for the on-disk cache. Auto-determined.                                                                                                  | None                      |
+| cache 	                | Custom cache implementation (must extend BaseCache). See [custom cache](#custom-cache) for more information                                       | BaseCache                 |
+| scheduler 	            | Custom APScheduler instance.	Auto-created.                                                                                                       | BaseScheduler             |
+| verbose_log_level 	    | Python logging level for debugging feature flag failures. See https://docs.python.org/3/library/logging.html#logging-levels for more information.	| 30                        |
+| scheduler_executor 	    | APScheduler executor name to use.	                                                                                                                | None                      |
+| multiple_instance_mode    | How to handle multiple client instances (BLOCK, WARN, SILENTLY_ALLOW).	                                                                        | WARN                      |
+| event_callback 	        | Function to handle impression events. See [impression data](#impression-data) for more information.                                               | None                      |
+
+### Bootstrap
+
+By default, the Python SDK fetches your feature toggles from the Unleash API at startup. If you want to make your SDK more resilient (e.g., during network outages), you can bootstrap the client with a local or remote toggle config.
+
+How it works:
+
+- Use a FileCache (or your own BaseCache implementation).
+
+- Pre-seed it with feature toggles using bootstrap_from_dict, bootstrap_from_file, or bootstrap_from_url.
+
+- Pass your cache to the UnleashClient on startup.
+
+The default FileCache has built-in methods for bootstrapping from a dictionary, file, or URL.
+
+#### Bootstrap from dict
 
 ```python
-client.is_enabled("my_toggle", fallback_function=lambda feature_name, context: True)
+from UnleashClient.cache import FileCache
+from UnleashClient import UnleashClient
+
+# Create and seed the cache
+cache = FileCache("MY_CACHE")
+cache.bootstrap_from_dict({
+    "version": 2,
+    "features": [
+        {
+            "name": "my_toggle",
+            "enabled": True,
+            "strategies": [{"name": "default"}],
+        }
+    ]
+})
+
+client = UnleashClient(
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    cache=cache
+)
+
 ```
 
-The fallback function **must** accept the feature name and context as positional arguments in that order.
+#### Bootstrap from file
 
-The client will evaluate the fallback function only if an exception occurs when calling the `is_enabled()` method. This happens when the client can't find the feature flag. The client _may_ also throw other, general exceptions.
+```python
+from pathlib import Path
+from UnleashClient.cache import FileCache
 
-For more information about usage, see the [Usage documentation](https://docs.getunleash.io/unleash-client-python/usage.html).
+cache = FileCache("MY_CACHE")
+cache.bootstrap_from_file(Path("/path/to/your_bootstrap.json"))
+
+client = UnleashClient(
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    cache=cache
+)
+```
 
-### Getting a variant
+#### Bootstrap from URL
 
-Checking for a variant:
 ```python
-context = {'userId': '2'}  # Context must have userId, sessionId, or remoteAddr.  If none are present, distribution will be random.
+from UnleashClient.cache import FileCache
 
-variant = client.get_variant("variant_toggle", context)
+cache = FileCache("MY_CACHE")
+cache.bootstrap_from_url("https://your-server/bootstrap.json")
+
+client = UnleashClient(
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    cache=cache
+)
 
-print(variant)
-> {
->    "name": "variant1",
->    "payload": {
->        "type": "string",
->        "value": "val1"
->        },
->    "enabled": True
-> }
 ```
 
-For more information about variants, see the [Variant documentation](https://docs.getunleash.io/advanced/toggle_variants).
+### Custom strategies
+
+The Python SDK lets you define [custom activation strategies](https://docs.getunleash.io/reference/custom-activation-strategies) if the built-in ones don’t cover your needs. This gives you more fine grained control over how your features evaluate.
+
+A custom strategy is just a class that implements an apply method.
 
-## Developing
+``` python
 
-For development, you'll need to setup the environment to run the tests. This repository is using
-tox to run the test suite to test against multiple versions of Python. Running the tests is as simple as running this command in the makefile:
+class ActiveForEmailStrategy:
+    def apply(self, parameters: dict, context: dict = None) -> bool:
+        # Decide if the feature is active for this context
+        return context.get("email") in parameters
 
 ```
-tox -e py311
+
+Once you’ve defined your strategy, register it when you initialize the client. The key must match the strategy name in Unleash exactly.
+
+``` python
+## You should have a custom strategy defined in Unleash called 'EmailStrategy'
+my_custom_strategies = {
+    "EmailStrategy": ActiveForEmailStrategy()
+}
+
+client = UnleashClient(
+
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    custom_headers={'Authorization': '<API token>'},
+    custom_strategies = my_custom_strategies
+)
+
 ```
 
-This command will take care of downloading the client specifications and putting them in the correct place in the repository, and install all the dependencies you need.
+### Impression data
 
-However, there are some caveats to this method. There is no easy way to run a single test, and running the entire test suite can be slow.
+The Python SDK supports [impression data](https://docs.getunleash.io/reference/impression-data). This lets you capture an event for every feature toggle evaluation.
 
-### Manual setup
+**Note**: The SDK does not include a built-in event bus — you’ll need to provide your own. The example below shows how to use [Blinker](https://pypi.org/project/blinker/) to send signals.
 
-First, make sure you have pip or pip3 installed.
+To use impression data:
+- Enable impression data on your feature flags in the Unleash UI.
+- Provide an event_callback function when you initialize the client.
 
-Then setup your viritual environment:
+Your callback must accept a single UnleashEvent. You can log it, store it, or send it to another system.
 
-Linux & Mac:
+```python
+from blinker import signal
+from UnleashClient import UnleashClient
+from UnleashClient.events import UnleashEvent
 
-```
-python3 -m venv venv
-source venv/bin/activate
-```
+send_data = signal('send-data')
 
-Windows + cmd:
+@send_data.connect
+def receive_data(sender, **kw):
+    print("Caught signal from %r, data %r" % (sender, kw))
 
-```
-python -m venv venv
-venv\Scripts\activate.bat
-```
+def example_callback(event: UnleashEvent):
+    send_data.send('anonymous', data=event)
 
-Powershell:
+client = UnleashClient(
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    custom_headers={'Authorization': '<API token>'},
+    event_callback=example_callback
+)
+client.initialize_client()
+client.is_enabled("testFlag")
 
-```
-python -m venv venv
-venv\Scripts\activate.bat
 ```
 
-Once you've done your setup, run:
-```
-pip install -r requirements.txt
-```
+Impression callbacks run in-process — keep them fast to avoid blocking your app.
 
-Run the get-spec script to download the client specifications tests:
-```
-./scripts/get-spec.sh
-```
+### Custom cache
 
-Now you can run the tests by running `pytest` in the root directory.
+By default, the Python SDK stores feature toggles in an on-disk cache using fcache. If you need a different storage backend, for example, Redis, memory-only, or a custom database, you can provide your own cache implementation.
 
-In order to run a single test, run the following command:
+Below is an example custom CustomCache using fcache under the hood.
 
+```python
+from typing import Optional, Any
+from UnleashClient.cache import BaseCache
+from fcache.cache import FileCache as _FileCache
+
+class CustomCache(BaseCache):
+    # This is specific for FileCache.  Depending on the cache you're using, this may look different!
+    def __init__(self, name: str, directory: Optional[str] = None):
+        self._cache = _FileCache(name, app_cache_dir=directory)
+
+    def set(self, key: str, value: Any):
+        self._cache[key] = value
+        self._cache.sync()
+
+    def mset(self, data: dict):
+        self._cache.update(data)
+        self._cache.sync()
+
+    def get(self, key: str, default: Optional[Any] = None):
+        return self._cache.get(key, default)
+
+    def exists(self, key: str):
+        return key in self._cache
+
+    def destroy(self):
+        return self._cache.delete()
 ```
-pytest testfile.py::function_name
+Pass your cache instance to the client with the cache argument:
 
-# example: pytest tests/unit_tests/test_client.py::test_consistent_results
+```python
+client = UnleashClient(
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    cache=CustomCache("my-cache")
+)
 ```
 
-### Linting
+## Running in multi-process setups
 
-In order to lint all the files you can run the following command:
+The Python SDK runs a background thread to keep feature toggles in sync with the Unleash server. Some runtime environments, like WSGI servers and Celery workers, need extra setup to make sure the SDK works correctly.
 
+### WSGI
+
+When using WSGI servers (e.g., for Flask or Django apps), be aware that:
+
+- Many WSGI setups disable threading by default. The SDK needs threads to poll for updates in the background.
+- Make sure to set `enable-threads` in your WSGI config.
+
+When running under uWSGI with multiple processes (using the `--processes` option), you may need to enable the `lazy-apps` option. This ensures each process gets a fresh SDK instance.
+
+See [The Art of Graceful Reloading](https://uwsgi-docs.readthedocs.io/en/latest/articles/TheArtOfGracefulReloading.html#preforking-vs-lazy-apps-vs-lazy) for more details.
+
+### Celery
+
+When using the SDK in Celery tasks, make sure you initialize it inside the worker_process_init event. Otherwise, the worker may run but won’t poll for feature toggle updates.
+
+```python
+from UnleashClient import UnleashClient
+from celery.signals import worker_process_init
+
+client = UnleashClient(
+    url="https://YOUR-API-URL",
+    app_name="my-python-app",
+    custom_headers={'Authorization': '<API token>'}
+)
+
+@worker_process_init.connect
+def configure_workers(sender=None, conf=None, **kwargs):
+    client.initialize_client()
 ```
-make fmt
-```
+
+## Contributing and development
+
+We love community input! If you’d like to report a bug, propose a feature, or improve the SDK, please read our [contribution guide](CONTRIBUTING.md) for how to get started.
+
+For instructions on setting up your development environment, running tests, and publishing, see our [development documentation](DEVELOPMENT.md).
+
+## License
+
+This project is [MIT licensed](https://opensource.org/licenses/MIT).
diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py
index 182ed10..d6b9d28 100644
--- a/UnleashClient/__init__.py
+++ b/UnleashClient/__init__.py
@@ -24,7 +24,12 @@ from UnleashClient.constants import (
     SDK_NAME,
     SDK_VERSION,
 )
-from UnleashClient.events import UnleashEvent, UnleashEventType
+from UnleashClient.events import (
+    BaseEvent,
+    UnleashEvent,
+    UnleashEventType,
+    UnleashReadyEvent,
+)
 from UnleashClient.loader import load_features
 from UnleashClient.periodic_tasks import (
     aggregate_and_send_metrics,
@@ -46,6 +51,37 @@ _BASE_CONTEXT_FIELDS = [
 ]
 
 
+def build_ready_callback(
+    event_callback: Optional[Callable[[BaseEvent], None]] = None,
+) -> Optional[Callable]:
+    """
+    Builds a callback function that can be used to notify when the Unleash client is ready.
+    """
+
+    if not event_callback:
+        return None
+
+    already_fired = False
+
+    def ready_callback() -> None:
+        """
+        Callback function to notify that the Unleash client is ready.
+        This will only call the event_callback once.
+        """
+        nonlocal already_fired
+        if already_fired:
+            return
+        if event_callback:
+            event = UnleashReadyEvent(
+                event_type=UnleashEventType.READY,
+                event_id=uuid.uuid4(),
+            )
+            already_fired = True
+            event_callback(event)
+
+    return ready_callback
+
+
 # pylint: disable=dangerous-default-value
 class UnleashClient:
     """
@@ -99,7 +135,7 @@ class UnleashClient:
         scheduler: Optional[BaseScheduler] = None,
         scheduler_executor: Optional[str] = None,
         multiple_instance_mode: InstanceAllowType = InstanceAllowType.WARN,
-        event_callback: Optional[Callable[[UnleashEvent], None]] = None,
+        event_callback: Optional[Callable[[BaseEvent], None]] = None,
     ) -> None:
         custom_headers = custom_headers or {}
         custom_options = custom_options or {}
@@ -132,6 +168,7 @@ class UnleashClient:
         self.unleash_project_name = project_name
         self.unleash_verbose_log_level = verbose_log_level
         self.unleash_event_callback = event_callback
+        self._ready_callback = build_ready_callback(event_callback)
 
         self._do_instance_check(multiple_instance_mode)
 
@@ -283,12 +320,15 @@ class UnleashClient:
                         "request_timeout": self.unleash_request_timeout,
                         "request_retries": self.unleash_request_retries,
                         "project": self.unleash_project_name,
+                        "event_callback": self.unleash_event_callback,
+                        "ready_callback": self._ready_callback,
                     }
                     job_func: Callable = fetch_and_load_features
                 else:
                     job_args = {
                         "cache": self.cache,
                         "engine": self.engine,
+                        "ready_callback": self._ready_callback,
                     }
                     job_func = load_features
 
diff --git a/UnleashClient/events.py b/UnleashClient/events.py
index e90c53a..670489c 100644
--- a/UnleashClient/events.py
+++ b/UnleashClient/events.py
@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 from enum import Enum
+from json import loads
 from typing import Optional
 from uuid import UUID
 
@@ -11,17 +12,51 @@ class UnleashEventType(Enum):
 
     FEATURE_FLAG = "feature_flag"
     VARIANT = "variant"
+    FETCHED = "fetched"
+    READY = "ready"
 
 
 @dataclass
-class UnleashEvent:
+class BaseEvent:
     """
-    Dataclass capturing information from an Unleash feature flag or variant check.
+    Base event type for all events in the Unleash client.
     """
 
     event_type: UnleashEventType
     event_id: UUID
+
+
+@dataclass
+class UnleashEvent(BaseEvent):
+    """
+    Dataclass capturing information from an Unleash feature flag or variant check.
+    """
+
     context: dict
     enabled: bool
     feature_name: str
     variant: Optional[str] = ""
+
+
+@dataclass
+class UnleashReadyEvent(BaseEvent):
+    """
+    Event indicating that the Unleash client is ready.
+    """
+
+    pass
+
+
+@dataclass
+class UnleashFetchedEvent(BaseEvent):
+    """
+    Event indicating that the Unleash client has fetched feature flags.
+    """
+
+    raw_features: str
+
+    @property
+    def features(self) -> dict:
+        if not hasattr(self, "_parsed_payload"):
+            self._parsed_payload = loads(self.raw_features)["features"]
+        return self._parsed_payload
diff --git a/UnleashClient/loader.py b/UnleashClient/loader.py
index 05775b0..475b13d 100644
--- a/UnleashClient/loader.py
+++ b/UnleashClient/loader.py
@@ -1,3 +1,5 @@
+from typing import Callable, Optional
+
 from yggdrasil_engine.engine import UnleashEngine
 
 from UnleashClient.cache import BaseCache
@@ -8,6 +10,7 @@ from UnleashClient.utils import LOGGER
 def load_features(
     cache: BaseCache,
     engine: UnleashEngine,
+    ready_callback: Optional[Callable] = None,
 ) -> None:
     """
     Caching
@@ -27,6 +30,8 @@ def load_features(
 
     try:
         warnings = engine.take_state(feature_provisioning)
+        if ready_callback:
+            ready_callback()
         if warnings:
             LOGGER.warning(
                 "Some features were not able to be parsed correctly, they may not evaluate as expected"
diff --git a/UnleashClient/periodic_tasks/fetch_and_load.py b/UnleashClient/periodic_tasks/fetch_and_load.py
index cf25c90..262d11f 100644
--- a/UnleashClient/periodic_tasks/fetch_and_load.py
+++ b/UnleashClient/periodic_tasks/fetch_and_load.py
@@ -1,10 +1,12 @@
-from typing import Optional
+import uuid
+from typing import Callable, Optional
 
 from yggdrasil_engine.engine import UnleashEngine
 
 from UnleashClient.api import get_feature_toggles
 from UnleashClient.cache import BaseCache
 from UnleashClient.constants import ETAG, FEATURES_URL
+from UnleashClient.events import UnleashEventType, UnleashFetchedEvent
 from UnleashClient.loader import load_features
 from UnleashClient.utils import LOGGER
 
@@ -20,6 +22,8 @@ def fetch_and_load_features(
     request_retries: int,
     engine: UnleashEngine,
     project: Optional[str] = None,
+    event_callback: Optional[Callable] = None,
+    ready_callback: Optional[Callable] = None,
 ) -> None:
     (state, etag) = get_feature_toggles(
         url,
@@ -44,3 +48,14 @@ def fetch_and_load_features(
         cache.set(ETAG, etag)
 
     load_features(cache, engine)
+
+    if state:
+        if event_callback:
+            event = UnleashFetchedEvent(
+                event_type=UnleashEventType.FETCHED,
+                event_id=uuid.uuid4(),
+                raw_features=state,
+            )
+            event_callback(event)
+        if ready_callback:
+            ready_callback()


Test patch:
diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py
index d6e5592..64a7f3d 100644
--- a/tests/unit_tests/test_client.py
+++ b/tests/unit_tests/test_client.py
@@ -891,28 +891,24 @@ def test_multiple_instances_are_unique_on_api_key(caplog):
 @responses.activate
 def test_signals_feature_flag(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
     )
-    responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
+    flag_event = None
+    variant_event = None
 
     # Set up signals
     send_data = signal("send-data")
 
     @send_data.connect
     def receive_data(sender, **kw):
-        print("Caught signal from %r, data %r" % (sender, kw))
-
+        #  variant_event
         if kw["data"].event_type == UnleashEventType.FEATURE_FLAG:
-            assert kw["data"].feature_name == "testFlag"
-            assert kw["data"].enabled
+            nonlocal flag_event
+            flag_event = kw["data"]
         elif kw["data"].event_type == UnleashEventType.VARIANT:
-            assert kw["data"].feature_name == "testVariations"
-            assert kw["data"].enabled
-            assert kw["data"].variant == "VarA"
-
-        raise Exception("Random!")
+            nonlocal variant_event
+            variant_event = kw["data"]
 
     def example_callback(event: UnleashEvent):
         send_data.send("anonymous", data=event)
@@ -922,7 +918,8 @@ def test_signals_feature_flag(cache):
         URL,
         APP_NAME,
         refresh_interval=REFRESH_INTERVAL,
-        metrics_interval=METRICS_INTERVAL,
+        disable_registration=True,
+        disable_metrics=True,
         cache=cache,
         event_callback=example_callback,
     )
@@ -935,6 +932,121 @@ def test_signals_feature_flag(cache):
     variant = unleash_client.get_variant("testVariations", context={"userId": "2"})
     assert variant["name"] == "VarA"
 
+    assert flag_event.feature_name == "testFlag"
+    assert flag_event.enabled
+
+    assert variant_event.feature_name == "testVariations"
+    assert variant_event.enabled
+    assert variant_event.variant == "VarA"
+
+
+@responses.activate
+def test_fetch_signal(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
+    )
+    responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
+    trapped_event = None
+
+    # Set up signals
+    send_data = signal("send-data")
+
+    @send_data.connect
+    def receive_data(sender, **kw):
+
+        if kw["data"].event_type == UnleashEventType.FETCHED:
+            nonlocal trapped_event
+            trapped_event = kw["data"]
+
+    def example_callback(event: UnleashEvent):
+        send_data.send("anonymous", data=event)
+
+    # Set up Unleash
+    unleash_client = UnleashClient(
+        URL,
+        APP_NAME,
+        refresh_interval=REFRESH_INTERVAL,
+        metrics_interval=METRICS_INTERVAL,
+        cache=cache,
+        event_callback=example_callback,
+    )
+
+    # Create Unleash client and check initial load
+    unleash_client.initialize_client()
+    time.sleep(1)
+
+    assert trapped_event.features[0]["name"] == "testFlag"
+
+
+@responses.activate
+def test_ready_signal(cache):
+    responses.add(
+        responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
+    )
+    trapped_events = 0
+
+    # Set up signals
+    send_data = signal("send-data")
+
+    @send_data.connect
+    def receive_data(sender, **kw):
+        if kw["data"].event_type == UnleashEventType.READY:
+            nonlocal trapped_events
+            trapped_events += 1
+
+    def example_callback(event: UnleashEvent):
+        send_data.send("anonymous", data=event)
+
+    unleash_client = UnleashClient(
+        URL,
+        APP_NAME,
+        refresh_interval=1,  # minimum interval is 1 second
+        disable_metrics=True,
+        disable_registration=True,
+        cache=cache,
+        event_callback=example_callback,
+    )
+
+    unleash_client.initialize_client()
+    time.sleep(2)
+
+    assert trapped_events == 1
+
+
+def test_ready_signal_works_with_bootstrapping():
+    cache = FileCache("MOCK_CACHE")
+    cache.bootstrap_from_dict(MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE)
+
+    trapped_events = 0
+
+    # Set up signals
+    send_data = signal("send-data")
+
+    @send_data.connect
+    def receive_data(sender, **kw):
+        if kw["data"].event_type == UnleashEventType.READY:
+            nonlocal trapped_events
+            trapped_events += 1
+
+    def example_callback(event: UnleashEvent):
+        send_data.send("anonymous", data=event)
+
+    unleash_client = UnleashClient(
+        url=URL,
+        app_name=APP_NAME,
+        cache=cache,
+        disable_metrics=True,
+        disable_registration=True,
+        event_callback=example_callback,
+    )
+
+    unleash_client.initialize_client(fetch_toggles=False)
+    time.sleep(1)
+
+    assert trapped_events == 1
+
 
 def test_context_handles_numerics():
     cache = FileCache("MOCK_CACHE")