diff --git a/optimizely/config_manager.py b/optimizely/config_manager.py index f8a67c9b6..286551f5f 100644 --- a/optimizely/config_manager.py +++ b/optimizely/config_manager.py @@ -21,6 +21,7 @@ from . import exceptions as optimizely_exceptions from . import logger as optimizely_logger from . import project_config +from .closeable import Closeable from .error_handler import NoOpErrorHandler from .notification_center import NotificationCenter from .helpers import enums @@ -29,283 +30,301 @@ ABC = abc.ABCMeta('ABC', (object,), {'__slots__': ()}) -class BaseConfigManager(ABC): - """ Base class for Optimizely's config manager. """ +class BaseConfigManager(ABC, Closeable): + """ Base class for Optimizely's config manager. """ - def __init__(self, - logger=None, - error_handler=None, - notification_center=None): - """ Initialize config manager. + def __init__(self, + logger=None, + error_handler=None, + notification_center=None): + """ Initialize config manager. - Args: - logger: Provides a logger instance. - error_handler: Provides a handle_error method to handle exceptions. - notification_center: Provides instance of notification_center.NotificationCenter. - """ - self.logger = optimizely_logger.adapt_logger(logger or optimizely_logger.NoOpLogger()) - self.error_handler = error_handler or NoOpErrorHandler() - self.notification_center = notification_center or NotificationCenter(self.logger) - self._validate_instantiation_options() + Args: + logger: Provides a logger instance. + error_handler: Provides a handle_error method to handle exceptions. + notification_center: Provides instance of notification_center.NotificationCenter. + """ + self.logger = optimizely_logger.adapt_logger(logger or optimizely_logger.NoOpLogger()) + self.error_handler = error_handler or NoOpErrorHandler() + self.notification_center = notification_center or NotificationCenter(self.logger) + self._validate_instantiation_options() - def _validate_instantiation_options(self): - """ Helper method to validate all parameters. + def _validate_instantiation_options(self): + """ Helper method to validate all parameters. - Raises: - Exception if provided options are invalid. - """ - if not validator.is_logger_valid(self.logger): - raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('logger')) + Raises: + Exception if provided options are invalid. + """ + if not validator.is_logger_valid(self.logger): + raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('logger')) - if not validator.is_error_handler_valid(self.error_handler): - raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('error_handler')) + if not validator.is_error_handler_valid(self.error_handler): + raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('error_handler')) - if not validator.is_notification_center_valid(self.notification_center): - raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('notification_center')) + if not validator.is_notification_center_valid(self.notification_center): + raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('notification_center')) - @abc.abstractmethod - def get_config(self): - """ Get config for use by optimizely.Optimizely. - The config should be an instance of project_config.ProjectConfig.""" - pass + @abc.abstractmethod + def get_config(self): + """ Get config for use by optimizely.Optimizely. + The config should be an instance of project_config.ProjectConfig.""" + pass class StaticConfigManager(BaseConfigManager): - """ Config manager that returns ProjectConfig based on provided datafile. """ - - def __init__(self, - datafile=None, - logger=None, - error_handler=None, - notification_center=None, - skip_json_validation=False): - """ Initialize config manager. Datafile has to be provided to use. - - Args: - datafile: JSON string representing the Optimizely project. - logger: Provides a logger instance. - error_handler: Provides a handle_error method to handle exceptions. - notification_center: Notification center to generate config update notification. - skip_json_validation: Optional boolean param which allows skipping JSON schema - validation upon object invocation. By default - JSON schema validation will be performed. - """ - super(StaticConfigManager, self).__init__(logger=logger, - error_handler=error_handler, - notification_center=notification_center) - self._config = None - self.validate_schema = not skip_json_validation - self._set_config(datafile) - - def _set_config(self, datafile): - """ Looks up and sets datafile and config based on response body. - - Args: - datafile: JSON string representing the Optimizely project. - """ - - if self.validate_schema: - if not validator.is_datafile_valid(datafile): - self.logger.error(enums.Errors.INVALID_INPUT.format('datafile')) - return + """ Config manager that returns ProjectConfig based on provided datafile. """ + + def __init__(self, + datafile=None, + logger=None, + error_handler=None, + notification_center=None, + skip_json_validation=False): + """ Initialize config manager. Datafile has to be provided to use. + + Args: + datafile: JSON string representing the Optimizely project. + logger: Provides a logger instance. + error_handler: Provides a handle_error method to handle exceptions. + notification_center: Notification center to generate config update notification. + skip_json_validation: Optional boolean param which allows skipping JSON schema + validation upon object invocation. By default + JSON schema validation will be performed. + """ + super(StaticConfigManager, self).__init__(logger=logger, + error_handler=error_handler, + notification_center=notification_center) + self._config = None + self.validate_schema = not skip_json_validation + self._set_config(datafile) + + def _set_config(self, datafile): + """ Looks up and sets datafile and config based on response body. + + Args: + datafile: JSON string representing the Optimizely project. + """ + + if self.validate_schema: + if not validator.is_datafile_valid(datafile): + self.logger.error(enums.Errors.INVALID_INPUT.format('datafile')) + return + + error_msg = None + error_to_handle = None + config = None - error_msg = None - error_to_handle = None - config = None - - try: - config = project_config.ProjectConfig(datafile, self.logger, self.error_handler) - except optimizely_exceptions.UnsupportedDatafileVersionException as error: - error_msg = error.args[0] - error_to_handle = error - except: - error_msg = enums.Errors.INVALID_INPUT.format('datafile') - error_to_handle = optimizely_exceptions.InvalidInputException(error_msg) - finally: - if error_msg: - self.logger.error(error_msg) - self.error_handler.handle_error(error_to_handle) + try: + config = project_config.ProjectConfig(datafile, self.logger, self.error_handler) + except optimizely_exceptions.UnsupportedDatafileVersionException as error: + error_msg = error.args[0] + error_to_handle = error + except: + error_msg = enums.Errors.INVALID_INPUT.format('datafile') + error_to_handle = optimizely_exceptions.InvalidInputException(error_msg) + finally: + if error_msg: + self.logger.error(error_msg) + self.error_handler.handle_error(error_to_handle) + return + + previous_revision = self._config.get_revision() if self._config else None + + if previous_revision == config.get_revision(): return - previous_revision = self._config.get_revision() if self._config else None + self._config = config + self.notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE) + self.logger.debug( + 'Received new datafile and updated config. ' + 'Old revision number: {}. New revision number: {}.'.format(previous_revision, config.get_revision()) + ) - if previous_revision == config.get_revision(): - return + def get_config(self): + """ Returns instance of ProjectConfig. - self._config = config - self.notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE) - self.logger.debug( - 'Received new datafile and updated config. ' - 'Old revision number: {}. New revision number: {}.'.format(previous_revision, config.get_revision()) - ) + Returns: + ProjectConfig. None if not set. + """ + return self._config - def get_config(self): - """ Returns instance of ProjectConfig. - Returns: - ProjectConfig. None if not set. - """ - return self._config +class PollingConfigManager(StaticConfigManager): + """ Config manager that polls for the datafile and updated ProjectConfig based on an update interval. """ + + def __init__(self, + sdk_key=None, + datafile=None, + update_interval=None, + url=None, + url_template=None, + logger=None, + error_handler=None, + notification_center=None, + skip_json_validation=False): + """ Initialize config manager. One of sdk_key or url has to be set to be able to use. + + Args: + sdk_key: Optional string uniquely identifying the datafile. + datafile: Optional JSON string representing the project. + update_interval: Optional floating point number representing time interval in seconds + at which to request datafile and set ProjectConfig. + url: Optional string representing URL from where to fetch the datafile. If set it supersedes the sdk_key. + url_template: Optional string template which in conjunction with sdk_key + determines URL from where to fetch the datafile. + logger: Provides a logger instance. + error_handler: Provides a handle_error method to handle exceptions. + notification_center: Notification center to generate config update notification. + skip_json_validation: Optional boolean param which allows skipping JSON schema + validation upon object invocation. By default + JSON schema validation will be performed. + + """ + super(PollingConfigManager, self).__init__(datafile=datafile, + logger=logger, + error_handler=error_handler, + notification_center=notification_center, + skip_json_validation=skip_json_validation) + self.datafile_url = self.get_datafile_url(sdk_key, url, + url_template or enums.ConfigManager.DATAFILE_URL_TEMPLATE) + self.set_update_interval(update_interval) + self.last_modified = None + self._disposed = False + self.control_flag = True + self._polling_thread = threading.Thread(target=self._run) + self._polling_thread.setDaemon(True) + self._polling_thread.start() + @property + def disposed(self): + return self._disposed + + @staticmethod + def get_datafile_url(sdk_key, url, url_template): + """ Helper method to determine URL from where to fetch the datafile. + + Args: + sdk_key: Key uniquely identifying the datafile. + url: String representing URL from which to fetch the datafile. + url_template: String representing template which is filled in with + SDK key to determine URL from which to fetch the datafile. + + Returns: + String representing URL to fetch datafile from. + + Raises: + optimizely.exceptions.InvalidInputException if: + - One of sdk_key or url is not provided. + - url_template is invalid. + """ + # Ensure that either is provided by the user. + if sdk_key is None and url is None: + raise optimizely_exceptions.InvalidInputException('Must provide at least one of sdk_key or url.') + + # Return URL if one is provided or use template and SDK key to get it. + if url is None: + try: + return url_template.format(sdk_key=sdk_key) + except (AttributeError, KeyError): + raise optimizely_exceptions.InvalidInputException( + 'Invalid url_template {} provided.'.format(url_template)) + + return url + + def set_update_interval(self, update_interval): + """ Helper method to set frequency at which datafile has to be polled and ProjectConfig updated. + + Args: + update_interval: Time in seconds after which to update datafile. + """ + if not update_interval: + update_interval = enums.ConfigManager.DEFAULT_UPDATE_INTERVAL + self.logger.debug('Set config update interval to default value {}.'.format(update_interval)) + + if not isinstance(update_interval, (int, float)): + raise optimizely_exceptions.InvalidInputException( + 'Invalid update_interval "{}" provided.'.format(update_interval) + ) -class PollingConfigManager(StaticConfigManager): - """ Config manager that polls for the datafile and updated ProjectConfig based on an update interval. """ - - def __init__(self, - sdk_key=None, - datafile=None, - update_interval=None, - url=None, - url_template=None, - logger=None, - error_handler=None, - notification_center=None, - skip_json_validation=False): - """ Initialize config manager. One of sdk_key or url has to be set to be able to use. - - Args: - sdk_key: Optional string uniquely identifying the datafile. - datafile: Optional JSON string representing the project. - update_interval: Optional floating point number representing time interval in seconds - at which to request datafile and set ProjectConfig. - url: Optional string representing URL from where to fetch the datafile. If set it supersedes the sdk_key. - url_template: Optional string template which in conjunction with sdk_key - determines URL from where to fetch the datafile. - logger: Provides a logger instance. - error_handler: Provides a handle_error method to handle exceptions. - notification_center: Notification center to generate config update notification. - skip_json_validation: Optional boolean param which allows skipping JSON schema - validation upon object invocation. By default - JSON schema validation will be performed. - - """ - super(PollingConfigManager, self).__init__(datafile=datafile, - logger=logger, - error_handler=error_handler, - notification_center=notification_center, - skip_json_validation=skip_json_validation) - self.datafile_url = self.get_datafile_url(sdk_key, url, - url_template or enums.ConfigManager.DATAFILE_URL_TEMPLATE) - self.set_update_interval(update_interval) - self.last_modified = None - self._polling_thread = threading.Thread(target=self._run) - self._polling_thread.setDaemon(True) - self._polling_thread.start() - - @staticmethod - def get_datafile_url(sdk_key, url, url_template): - """ Helper method to determine URL from where to fetch the datafile. - - Args: - sdk_key: Key uniquely identifying the datafile. - url: String representing URL from which to fetch the datafile. - url_template: String representing template which is filled in with - SDK key to determine URL from which to fetch the datafile. - - Returns: - String representing URL to fetch datafile from. - - Raises: - optimizely.exceptions.InvalidInputException if: - - One of sdk_key or url is not provided. - - url_template is invalid. - """ - # Ensure that either is provided by the user. - if sdk_key is None and url is None: - raise optimizely_exceptions.InvalidInputException('Must provide at least one of sdk_key or url.') - - # Return URL if one is provided or use template and SDK key to get it. - if url is None: + # If polling interval is less than minimum allowed interval then set it to default update interval. + if update_interval < enums.ConfigManager.MIN_UPDATE_INTERVAL: + self.logger.debug('update_interval value {} too small. Defaulting to {}'.format( + update_interval, + enums.ConfigManager.DEFAULT_UPDATE_INTERVAL) + ) + update_interval = enums.ConfigManager.DEFAULT_UPDATE_INTERVAL + + self.update_interval = update_interval + + def set_last_modified(self, response_headers): + """ Looks up and sets last modified time based on Last-Modified header in the response. + + Args: + response_headers: requests.Response.headers + """ + self.last_modified = response_headers.get(enums.HTTPHeaders.LAST_MODIFIED) + + def _handle_response(self, response): + """ Helper method to handle response containing datafile. + + Args: + response: requests.Response + """ try: - return url_template.format(sdk_key=sdk_key) - except (AttributeError, KeyError): - raise optimizely_exceptions.InvalidInputException( - 'Invalid url_template {} provided.'.format(url_template)) + response.raise_for_status() + except requests_exceptions.HTTPError as err: + self.logger.error('Fetching datafile from {} failed. Error: {}'.format(self.datafile_url, str(err))) + return - return url + # Leave datafile and config unchanged if it has not been modified. + if response.status_code == http_status_codes.not_modified: + self.logger.debug('Not updating config as datafile has not updated since {}.'.format(self.last_modified)) + return - def set_update_interval(self, update_interval): - """ Helper method to set frequency at which datafile has to be polled and ProjectConfig updated. + self.set_last_modified(response.headers) + self._set_config(response.content) - Args: - update_interval: Time in seconds after which to update datafile. - """ - if not update_interval: - update_interval = enums.ConfigManager.DEFAULT_UPDATE_INTERVAL - self.logger.debug('Set config update interval to default value {}.'.format(update_interval)) + def fetch_datafile(self): + """ Fetch datafile and set ProjectConfig. """ - if not isinstance(update_interval, (int, float)): - raise optimizely_exceptions.InvalidInputException( - 'Invalid update_interval "{}" provided.'.format(update_interval) - ) + request_headers = {} + if self.last_modified: + request_headers[enums.HTTPHeaders.IF_MODIFIED_SINCE] = self.last_modified - # If polling interval is less than minimum allowed interval then set it to default update interval. - if update_interval < enums.ConfigManager.MIN_UPDATE_INTERVAL: - self.logger.debug('update_interval value {} too small. Defaulting to {}'.format( - update_interval, - enums.ConfigManager.DEFAULT_UPDATE_INTERVAL) - ) - update_interval = enums.ConfigManager.DEFAULT_UPDATE_INTERVAL - - self.update_interval = update_interval - - def set_last_modified(self, response_headers): - """ Looks up and sets last modified time based on Last-Modified header in the response. - - Args: - response_headers: requests.Response.headers - """ - self.last_modified = response_headers.get(enums.HTTPHeaders.LAST_MODIFIED) - - def _handle_response(self, response): - """ Helper method to handle response containing datafile. - - Args: - response: requests.Response - """ - try: - response.raise_for_status() - except requests_exceptions.HTTPError as err: - self.logger.error('Fetching datafile from {} failed. Error: {}'.format(self.datafile_url, str(err))) - return - - # Leave datafile and config unchanged if it has not been modified. - if response.status_code == http_status_codes.not_modified: - self.logger.debug('Not updating config as datafile has not updated since {}.'.format(self.last_modified)) - return - - self.set_last_modified(response.headers) - self._set_config(response.content) - - def fetch_datafile(self): - """ Fetch datafile and set ProjectConfig. """ - - request_headers = {} - if self.last_modified: - request_headers[enums.HTTPHeaders.IF_MODIFIED_SINCE] = self.last_modified - - response = requests.get(self.datafile_url, - headers=request_headers, - timeout=enums.ConfigManager.REQUEST_TIMEOUT) - self._handle_response(response) - - @property - def is_running(self): - """ Check if polling thread is alive or not. """ - return self._polling_thread.is_alive() - - def _run(self): - """ Triggered as part of the thread which fetches the datafile and sleeps until next update interval. """ - try: - while self.is_running: - self.fetch_datafile() - time.sleep(self.update_interval) - except (OSError, OverflowError) as err: - self.logger.error('Error in time.sleep. ' - 'Provided update_interval value may be too big. Error: {}'.format(str(err))) - raise - - def start(self): - """ Start the config manager and the thread to periodically fetch datafile. """ - if not self.is_running: - self._polling_thread.start() + response = requests.get(self.datafile_url, + headers=request_headers, + timeout=enums.ConfigManager.REQUEST_TIMEOUT) + self._handle_response(response) + + @property + def is_running(self): + """ Check if polling thread is alive or not. """ + return self._polling_thread.is_alive() + + def _run(self): + """ Triggered as part of the thread which fetches the datafile and sleeps until next update interval. """ + try: + while self.is_running and not self.disposed: + self.fetch_datafile() + time.sleep(self.update_interval) + except (OSError, OverflowError) as err: + self.logger.error('Error in time.sleep. ' + 'Provided update_interval value may be too big. Error: {}'.format(str(err))) + raise + + def start(self): + """ Start the config manager and the thread to periodically fetch datafile. """ + if not self.is_running and not self.disposed: + self._polling_thread.start() + + def close(self): + if self.disposed: + return + + self._polling_thread.join(2) + + if self.is_running: + self.logger.error('Timeout exceeded while attempting to close for 2000 ms.') + + self.logger.warning('Stopping Manager.') + self._disposed = True diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index cabd176ad..035c1f5dd 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -17,6 +17,7 @@ from . import event_builder from . import exceptions from . import logger as _logging +from .closeable import Closeable from .config_manager import StaticConfigManager, PollingConfigManager from .error_handler import NoOpErrorHandler as noop_error_handler from .event import event_factory, user_event_factory @@ -97,6 +98,11 @@ def __init__(self, self.event_builder = event_builder.EventBuilder() self.decision_service = decision_service.DecisionService(self.logger, user_profile_service) + self._disposed = False + + @property + def disposed(self): + return self._disposed def _validate_instantiation_options(self): """ Helper method to validate all instantiation parameters. @@ -742,3 +748,23 @@ def get_forced_variation(self, experiment_key, user_id): forced_variation = self.decision_service.get_forced_variation(project_config, experiment_key, user_id) return forced_variation.key if forced_variation else None + + def close(self): + """ Checks if event_processor and config_manager are closeable and calls close on them. """ + + if self.disposed: + return + + self._try_close(self.event_processor) + self._try_close(self.config_manager) + + self.is_valid = False + self._disposed = True + + def _try_close(self, obj): + """ Helper method which checks if obj implements close method and calls close() on it. """ + + if not isinstance(obj, Closeable): + return + + obj.close() diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index f8ee79000..21afdf7dc 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -3707,6 +3707,50 @@ def test_get_feature_variable_returns__default_value__complex_audience_match(sel opt_obj.get_feature_variable('feat2_with_var', 'z', 'user1', {}) ) + def test_close_invalidates_optimizely_object(self): + """ Test that close invalidates Optimizely instance. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict)) + opt_obj.close() + + self.assertFalse(opt_obj.is_valid) + + def test_close__disables_api_requests(self): + """ Test that close disables API requests and returns None. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict)) + + activate_before_close = opt_obj.activate('test_experiment', 'user_1') + self.assertIsNotNone(activate_before_close) + + opt_obj.close() + + activate_after_close = opt_obj.activate('test_experiment', 'user_1') + self.assertIsNone(activate_after_close) + + def test_close__also_closes_config_manager(self): + """ Test that close also closes config_manager. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict), sdk_key='some_key') + + opt_obj.close() + + self.assertFalse(opt_obj.is_valid) + self.assertTrue(opt_obj.disposed) + self.assertTrue(opt_obj.config_manager.disposed) + + def test_close__apis_do_not_crash_after_close(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict)) + opt_obj.close() + + self.assertFalse(opt_obj.is_valid) + self.assertIsNone(opt_obj.activate('test_experiment', 'user_1')) + self.assertIsNone(opt_obj.get_variation('test_experiment', 'user_1')) + self.assertIsNone(opt_obj.track('test_event', 'user_1')) + # why do we not return none in this case? + self.assertEqual([], opt_obj.get_enabled_features('user_1')) + self.assertIsNone(opt_obj.get_feature_variable('feat2_with_var', 'z', 'user1', {})) + class OptimizelyWithExceptionTest(base.BaseTest):