Source code for laboratory.experiment

from copy import deepcopy
from functools import wraps
import logging
import random
import traceback

from laboratory import exceptions
from laboratory.observation import Observation
from laboratory.result import Result


logger = logging.getLogger(__name__)


[docs]class Experiment(object): ''' Experiment base class. Handles running your control and candidate functions. Should be subclassed to add publishing functionality. :ivar string name: Experiment name :ivar bool raise_on_mismatch: Raise :class:`MismatchException` when experiment results do not match ''' def __init__(self, name='Experiment', context=None, raise_on_mismatch=False): ''' :param string name: Experiment name :param dict context: Experiment-wide context :param bool raise_on_mismatch: Raise if results do not match ''' self.name = name self._context = context or {} self.raise_on_mismatch = raise_on_mismatch self._control = None self._candidates = []
[docs] @classmethod def decorator(cls, candidate, *exp_args, **exp_kwargs): ''' Decorate a control function in order to conduct an experiment when called. :param callable candidate: your candidate function :param iterable exp_args: positional arguments passed to :class:`Experiment` :param dict exp_kwargs: keyword arguments passed to :class:`Experiment` Usage:: candidate_func = lambda: True @Experiment.decorator(candidate_func) def control_func(): return True ''' def wrapper(control): @wraps(control) def inner(*args, **kwargs): experiment = cls(*exp_args, **exp_kwargs) experiment.control(control, args=args, kwargs=kwargs) experiment.candidate(candidate, args=args, kwargs=kwargs) return experiment.conduct() return inner return wrapper
[docs] def control(self, control_func, args=None, kwargs=None, name='Control', context=None): ''' Set the experiment's control function. Must be set before ``conduct()`` is called. :param callable control_func: your control function :param iterable args: positional arguments to pass to your function :param dict kwargs: keyword arguments to pass to your function :param string name: a name for your observation :param dict context: observation-specific context :raises LaboratoryException: If attempting to set a second control case ''' if self._control is not None: raise exceptions.LaboratoryException( 'You have already established a control case' ) self._control = { 'func': control_func, 'args': args or [], 'kwargs': kwargs or {}, 'name': name, 'context': context or {}, }
[docs] def candidate(self, cand_func, args=None, kwargs=None, name='Candidate', context=None): ''' Adds a candidate function to an experiment. Can be used multiple times for multiple candidates. :param callable cand_func: your control function :param iterable args: positional arguments to pass to your function :param dict kwargs: keyword arguments to pass to your function :param string name: a name for your observation :param dict context: observation-specific context ''' self._candidates.append({ 'func': cand_func, 'args': args or [], 'kwargs': kwargs or {}, 'name': name, 'context': context or {}, })
[docs] def conduct(self, randomize=True): ''' Run control & candidate functions and return the control's return value. ``control()`` must be called first. :param bool randomize: controls whether we shuffle the order of execution between control and candidate :raise LaboratoryException: when no control case has been set :return: Control function's return value ''' if self._control is None: raise exceptions.LaboratoryException( 'Your experiment must contain a control case' ) # execute control and exit if experiment is not enabled if not self.enabled(): control = self._run_tested_func(raise_on_exception=True, **self._control) return control.value # otherwise, let's wrap an executor around all of our functions and randomise the ordering def get_func_executor(obs_def, is_control): """A lightweight wrapper around a tested function in order to retrieve state""" return lambda *a, **kw: (self._run_tested_func(raise_on_exception=is_control, **obs_def), is_control) funcs = [ get_func_executor(self._control, is_control=True), ] + [get_func_executor(cand, is_control=False,) for cand in self._candidates] if randomize: random.shuffle(funcs) control = None candidates = [] # go through the randomised list and execute the functions for func in funcs: observation, is_control = func() if is_control: control = observation else: candidates.append(observation) result = Result(self, control, candidates) try: self.publish(result) except Exception: msg = 'Exception occured when publishing %s experiment data' logger.exception(msg % self.name) return control.value
[docs] def enabled(self): ''' Enable the experiment? If false candidates will not be executed. :rtype: bool ''' return True
[docs] def compare(self, control, candidate): ''' Compares two :class:`Observation` instances. :param Observation control: The control block's :class:`Observation` :param Observation candidate: A candidate block's :class:`Observation` :raises MismatchException: If ``Experiment.raise_on_mismatch`` is True :return bool: match? ''' if candidate.failure or control.value != candidate.value: return self._handle_comparison_mismatch(control, candidate) return True
[docs] def publish(self, result): ''' Publish the results of an experiment. This is called after each experiment run. Exceptions that occur during publishing will be caught, but logged. By default this is a no-op. See :ref:`publishing`. :param Result result: The result of an experiment run ''' return
[docs] def get_context(self): ''' :return dict: Experiment-wide context ''' return self._context
def _run_tested_func(self, func, args, kwargs, name, context, raise_on_exception): ctx = deepcopy(self.get_context()) ctx.update(context) obs = Observation(name, ctx) obs.set_start_time() try: obs.record(func(*args, **kwargs)) except Exception as ex: obs.set_exception(ex) if raise_on_exception: raise finally: obs.set_end_time() return obs def _handle_comparison_mismatch(self, control, observation): if self.raise_on_mismatch: if observation.failure: tb = ''.join(traceback.format_exception(*observation.exc_info)) msg = '%s raised an exception:\n%s' % (observation.name, tb) else: msg = '%s does not match control value (%s != %s)' % ( observation.name, control.value, observation.value ) raise exceptions.MismatchException(msg) return False