"""
    flask_mab
    ~~~~~~~~~
    This module implements all the extension logic for
    Multi-armed bandit experiments on Flask apps.
    :copyright: (c) 2013 by `Mark Grey <http://www.deacondesperado.com>`_.
    :license: BSD, see LICENSE for more details.
"""
from flask import current_app, g, request
import json
import flask_mab.storage
import types
from bunch import Bunch
from flask import _request_ctx_stack
from functools import wraps
try:
    from flask import _app_ctx_stack as stack
except ImportError:
    from flask import _request_ctx_stack as stack
__version__ = "1.1.1"
[docs]def choose_arm(bandit):
    """Route decorator for registering an impression conveinently
    :param bandit: The bandit/experiment to register for
    :type bandit: string
    """
    def decorator(func):
        #runs @ service init
        if not hasattr(func, 'bandits'):
            func.bandits = []
        func.bandits.append(bandit)
        @wraps(func)
        def wrapper(*args, **kwargs):
            #runs at endpoint hit
            add_args = []
            for bandit in func.bandits:
                #Fetch from request first here?
                arm_id, arm_value = suggest_arm_for(bandit)
                add_args.append((bandit, arm_value))
            kwargs.update(add_args)
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
[docs]def reward_endpt(bandit, reward_val=1):
    """Route decorator for rewards.
    :param bandit: The bandit/experiment to register rewards
                   for using arm found in cookie.
    :type bandit: string
    :param reward: The amount of reward this endpoint should
                   give its winning arm
    :type reward: float
    """
    def decorator(func):
        if not hasattr(func, 'rewards'):
            func.rewards = []
        func.rewards.append((bandit, reward_val))
        @wraps(func)
        def wrapper(*args, **kwargs):
            for bandit, reward_amt in func.rewards:
                if bandit in request.bandits.keys():
                    request.bandits_reward.add((bandit, request.bandits[bandit], reward_amt))
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
[docs]class BanditMiddleware(object):
    """The main flask extension.
    Sets up all the necessary tracking for the bandit experiments
    """
    def __init__(self, app=None):
        """Attach MAB logic to a Flask application
        :param app: An optional Flask application
        """
        if app is not None:
            self.init_app(app)
    def _register_storage(self, app):
        storage_engine = getattr(
                flask_mab.storage,
                app.config.get('MAB_STORAGE_ENGINE', 'BanditStorage'))
        storage_opts = app.config.get('MAB_STORAGE_OPTS', tuple())
        storage_backend = storage_engine(*storage_opts)
        app.extensions['mab'].bandit_storage = storage_backend
[docs]    def init_app(self, app):
        """Attach Multi Armed Bandit to application and configure
        :param app: A flask application instance
        """
        app.config.setdefault('MAB_COOKIE_NAME', 'MAB')
        app.config.setdefault('MAB_COOKIE_PATH', '/')
        app.config.setdefault('MAB_COOKIE_TTL', None)
        app.config.setdefault('MAB_DEBUG_HEADERS', True)
        if not hasattr(app, 'extensions'):
            app.extensions = {}
        app.extensions['mab'] = Bunch()
        self._register_storage(app)
        if hasattr(app, 'teardown_appcontext'):
            app.teardown_appcontext(self.teardown)
        else:
            app.teardown_request(self.teardown)
        app.extensions['mab'].bandits = {}
        app.extensions['mab'].reward_endpts = []
        app.extensions['mab'].pull_endpts = []
        app.extensions['mab'].debug_headers = app.config.get('MAB_DEBUG_HEADERS', True)
        app.extensions['mab'].cookie_name = app.config.get('MAB_COOKIE_NAME', "MAB")
        self._init_detection(app)
 
[docs]    def teardown(self, *args, **kwargs):
        """Stub for old flask versions
        """
        pass
 
    def _init_detection(self, app):
        """
        Attaches all request before/after handlers for bandits.
        Nested functions are as follows
        * detect_last_bandits: Loads any arms already assigned to this user
                               from the cookie.
        * persist_bandits:     Saves bandits down to storage engine at the end
                               of the request
        * remember_bandit_arms: Sets the cookie for all requests that pulled an arm
        * send_debug_header: Attaches a header for the MAB to the HTTP response for easier debugging
        """
        @app.before_request
        def detect_last_bandits():
            bandits = request.cookies.get(app.extensions['mab'].cookie_name)
            request.bandits_save = False
            request.bandits_reward = set()
            if bandits:
                request.bandits = json.loads(bandits)
            else:
                request.bandits = {}
        @app.after_request
        def persist_bandits(response):
            app.extensions['mab'].bandit_storage.save(app.extensions['mab'].bandits)
            return response
        @app.after_request
        def remember_bandit_arms(response):
            if request.bandits_save:
                for bandit_id,arm in request.bandits.items():
                    #hook event for saving an impression here
                    app.extensions['mab'].bandits[bandit_id].pull_arm(arm)
            for bandit_id, arm, reward_amt in request.bandits_reward:
                try:
                    app.extensions['mab'].bandits[bandit_id].reward_arm(arm, reward_amt)
                    #hook event for saving a reward line here
                except KeyError:
                    raise MABConfigException("Bandit %s not found" % bandit_id)
            response.set_cookie(
                    app.extensions['mab'].cookie_name,
                    json.dumps(request.bandits))
            return response
        @app.after_request
        def send_debug_header(response):
            if app.extensions['mab'].debug_headers and request.bandits_save:
                response.headers['X-MAB-Debug'] = "STORE; "+';'.join(
                        ['%s:%s' % (key, val) for key, val in request.bandits.items()])
            elif app.extensions['mab'].debug_headers:
                response.headers['X-MAB-Debug'] = "SAVED; "+';'.join(['%s:%s' % (key, val) for key, val in request.bandits.items()])
            return response
        app.add_bandit = types.MethodType(add_bandit, app)
 
[docs]def add_bandit(app, name, bandit=None):
    """Attach a bandit for an experiment
    :param name: The name of the experiment, will be used for lookups
    :param bandit: The bandit to use for this experiment
    :type bandit: Bandit
    """
    saved_bandits = app.extensions['mab'].bandit_storage.load()
    if name in saved_bandits.keys():
        app.extensions['mab'].bandits[name] = saved_bandits[name]
    else:
        app.extensions['mab'].bandits[name] = bandit
 
[docs]def suggest_arm_for(key):
    """Get an experimental outcome by id.  The primary way the implementor interfaces with their
    experiments.
    Suggests arms if not in cookie, using cookie val if present
    :param key: The bandit/experiment to get a suggested arm for
    :type key: string
    :param also_pull: Should we register a pull/impression at the same time as suggesting
    :raises KeyError: in case requested experiment does not exist
    """
    app = current_app
    try:
        #Try to get the selected bandits from cookie
        arm = app.extensions['mab'].bandits[key][request.bandits[key]]
        return arm["id"], arm["value"]
    except (AttributeError, TypeError, KeyError) as err:
        #Assign an arm for a new client
        try:
            arm = app.extensions['mab'].bandits[key].suggest_arm()
            request.bandits[key] = arm["id"]
            request.bandits_save = True
            return arm["id"], arm["value"]
        except KeyError:
            raise MABConfigException("Bandit %s not found" % key)
 
[docs]class MABConfigException(Exception):
    """Raised when internal state in MAB setup is invalid"""
    pass