""" This module helps manage settings files. To use this module for your load tests: 1. Include the following two lines in your locustfile.py: from helpers import settings settings.init(__name__) 2. Create a settings file: "settings_files/<TEST MODULE NAME>.yml" We assume the first YAML document contains non-secret settings, and the second document contains secrets. The second YAML document is optional. Example settings file: --- hello: world --- # secrets password: set-me ... 3. Anywhere you need to use the settings data, make sure the settings module is imported, then use: settings.data['hello'] # returns 'world' or: settings.secrets['password'] # returns 'set-me' """ import os import yaml import logging import copy from pkg_resources import resource_filename from pprint import pformat LOG = logging.getLogger(__name__) data = None secrets = None class MissingRequiredSettingError(Exception): pass class MalformedSettingFileError(Exception): pass class Settings(object): """ Abstraction class for the standard edx-load-tests settings files syntax based on YAML. """ def __init__(self, data=None, secrets=None): self._data = data or {} self._secrets = secrets or {} @property def data(self): return self._data @property def secrets(self): return self._secrets def __eq__(self, other): return self.data == other.data and self.secrets == other.secrets def __ne__(self, other): return not self.__eq__(other) @classmethod def from_file(cls, settings_file): """ Factory method to create Settings instances from settings files. Arguments: settings_file (file): This open file object represents a settings file. Returns: A Settings instance containing the data and secrets from the given stream. """ data, secrets = cls.load_file(settings_file) return cls(data, secrets) @classmethod def load_file(cls, settings_file): """ Load the contents of the open file object as settings. Arguments: settings_file (file): This open file object represents a settings file. Returns: Two-tuple of dicts, where the first is settings data and the second is settings secrets. """ settings_documents = list(yaml.safe_load_all(settings_file)) # capture the data and secrets from their respective YAML documents data = None secrets = None if len(settings_documents) == 1: data, = settings_documents elif len(settings_documents) == 2: data, secrets = settings_documents elif len(settings_documents) > 2: raise MalformedSettingFileError("The settings file has more than two documents.") # YAML treats empty documents as None, we normalize it to an empty dict if data is None: data = {} if secrets is None: secrets = {} # Make sure we're actually returning dicts. Documents in YAML could # contain things other than mappings, but that wouldn't be valid for # settings. # not_mappings is a list of error messages: not_mappings = [ "{} has type '{}'".format(descriptor, type(obj).__name__) for descriptor, obj in {"first document": data, "second document": secrets}.items() if not isinstance(obj, dict) ] if not_mappings: # Say "mapping" in the error message instead of "dict" because # that's what they're called in YAML. raise MalformedSettingFileError( "One or more YAML documents in the settings file was not a mapping: {}.".format(', '.join(not_mappings)) ) return (data, secrets) def validate_required(self, required_data=(), required_secrets=()): """ Validate the settings keys by making sure the required ones are present. Arguments: required_data (iterable of str): dict keys which we will be confirming are in self.data and not mapped to None. required_secrets (iterable of str): dict keys which we will be confirming are in self.secrets and not mapped to None. Raises: MissingRequiredSettingError: if there are any missing settings keys """ missing_data_keys = [key for key in required_data if self.data.get(key) is None] missing_secret_keys = [key for key in required_secrets if self.secrets.get(key) is None] if missing_data_keys or missing_secret_keys: msgs = [] if missing_data_keys: msgs.append('Missing settings: {}.'.format(', '.join(missing_data_keys))) if missing_secret_keys: msgs.append('Missing secret settings: {}.'.format(', '.join(missing_secret_keys))) raise MissingRequiredSettingError(' '.join(msgs)) def dump(self, stream): """ Dump the generated settings file contents to the given stream. Arguments: stream (file): An open writable file object to dump settings into. """ # only dump the secrets yaml document if it is populated docs_to_dump = [self.data] if self.secrets: docs_to_dump.append(self.secrets) yaml.safe_dump_all( docs_to_dump, stream=stream, default_flow_style=False, # Represent objects using indented blocks # rather than inline enclosures. explicit_start=True, # Begin the first document with '---', per # our usual settings file syntax. ) def update(self, other_settings): """ Merge other_settings into this one. Existing settings are overridden by those in other_settings, and the rest should remain unchanged. Arguments: other_settings (Settings): Another settings instance. """ # Only work with a deep copy of other_settings, or else we might # transfer nested object references from other_settings into self, # causing self to contain a mix of shared and unshared values. other_settings_copy = copy.deepcopy(other_settings) self._data.update(other_settings_copy.data) self._secrets.update(other_settings_copy.secrets) def init(test_module_full_name, required_data=(), required_secrets=()): """ This is the primary entrypoint for this module. In short, it initializes the global data dict, finds/loads the settings files, and validates the data. """ global data global secrets if data is not None: raise RuntimeError('helpers.settings has been initialized twice!') # Find the correct settings file under the "settings_files" directory of # this package. The name of the settings file corresponds to the # name of the directory containing the locustfile. E.g. # "loadtests/lms/locustfile.py" reads settings data from # "settings_files/lms.yml". test_module_name = test_module_full_name.split('.')[-2] settings_filename = \ resource_filename('settings_files', '{}.yml'.format(test_module_name)) settings_filename = os.path.abspath(settings_filename) LOG.info('using settings file: {}'.format(settings_filename)) # load the settings file try: with open(settings_filename, 'r') as settings_file: settings = Settings.from_file(settings_file) except MalformedSettingFileError as e: raise MalformedSettingFileError("{}: {}".format(settings_filename, e.message)) # validation: make sure the required keys are present settings.validate_required(required_data, required_secrets) # produce output messages for future reference if settings.secrets: LOG.info('secrets loaded from the settings file') else: LOG.info('no secrets were specified in the settings file') LOG.info('loaded the following public settings:\n{}'.format( pformat(settings.data), )) # Copy loaded settings to globals. The globals are used in load test code # to refer to settings, not the Settings object. data = settings.data secrets = settings.secrets