"""
gutter.models
~~~~~~~~~~~~~~~
:copyright: (c) 2010-2012 DISQUS.
:license: Apache License 2.0, see LICENSE for more details.
"""
from gutter.client import signals
from functools import partial
import threading
[docs]class Switch(object):
"""
A switch encapsulates the concept of an item that is either 'on' or 'off'
depending on the input. The swich determines this by checking each of its
conditions and seeing if it applies to a certain input. All the switch does
is ask each of its Conditions if it applies to the provided input. Normally
any condition can be true for the Switch to be enabled for a particular
input, but of ``switch.componded`` is set to True, then **all** of the
switches conditions need to be true in order to be enabled.
See the Condition class for more information on what a Condition is and how
it checks to see if it's satisfied by an input.
"""
class states:
DISABLED = 1
SELECTIVE = 2
GLOBAL = 3
def __init__(self, name, state=states.DISABLED, compounded=False,
parent=None, concent=True, manager=None, label=None,
description=None):
self._name = str(name)
self.label = label
self.description = description
self.state = state
self.conditions = list()
self.compounded = compounded
self.parent = parent
self.concent = concent
self.children = []
self.manager = manager
self.reset()
@property
def name(self):
return self._name
def __repr__(self):
kwargs = dict(
state=self.state,
compounded=self.compounded,
concent=self.concent
)
parts = ["%s=%s" % (k, v) for k, v in kwargs.items()]
return '<Switch("%s") conditions=%s, %s>' % (
self.name,
len(self.conditions),
', '.join(parts)
)
def __eq__(self, other):
return (
self.name == other.name and
self.state is other.state and
self.compounded is other.compounded and
self.concent is other.concent
)
def __getstate__(self):
inner_dict = vars(self).copy()
inner_dict.pop('manager', False)
return inner_dict
def __setstate__(self, state):
### legacy conversion for 0.2 -> 0.3 ###
parent_or_parentname = state.pop('parent', '')
parent = getattr(parent_or_parentname, 'name', parent_or_parentname)
state['parent'] = parent
children = state.pop('children', [])
children = [getattr(child, 'name', child) for child in children]
state['children'] = children
if 'name' in state:
state['_name'] = state.pop('name')
### /legacy conversion for 0.2 -> 0.3 ###
self.__dict__ = state
if not hasattr(self, 'manager'):
setattr(self, 'manager', None)
[docs] def enabled_for(self, inpt):
"""
Checks to see if this switch is enabled for the provided input.
If ``compounded``, all switch conditions must be ``True`` for the swtich
to be enabled. Otherwise, *any* condition needs to be ``True`` for the
switch to be enabled.
The switch state is then checked to see if it is ``GLOBAL`` or
``DISABLED``. If it is not, then the switch is ``SELECTIVE`` and each
condition is checked.
Keyword Arguments:
inpt -- An instance of the ``Input`` class.
"""
signals.switch_checked.call(self)
signal_decorated = partial(self.__signal_and_return, inpt)
if self.state is self.states.GLOBAL:
return signal_decorated(True)
elif self.state is self.states.DISABLED:
return signal_decorated(False)
result = self.__enabled_func(cond.call(inpt) for cond in self.conditions)
return signal_decorated(result)
[docs] def save(self):
"""
Saves this switch in its manager (if present).
Equivilant to ``self.manager.update(self)``. If no ``manager`` is set
for the switch, this method is a no-op.
"""
if self.manager:
self.manager.update(self)
@property
[docs] def changes(self):
"""
A dicitonary of changes to the switch since last saved.
Switch changes are a dict in the following format::
{
'property_name': {'previous': value, 'current': value}
}
For example, if the switch name change from ``foo`` to ``bar``, the
changes dict will be in the following structure::
{
'name': {'previous': 'foo', 'current': 'bar'}
}
"""
return dict(list(self.__changes()))
@property
[docs] def changed(self):
"""
Boolean of if the switch has changed since last saved.
"""
return bool(list(self.__changes()))
[docs] def reset(self):
"""
Resets switch change tracking.
No switch properties are alterted, only the tracking of what has changed
is reset.
"""
self.__init_vars = vars(self).copy()
@property
def state_string(self):
state_vars = vars(self.states)
rev = dict(zip(state_vars.values(), state_vars))
return rev[self.state]
@property
def __enabled_func(self):
if self.compounded:
return all
else:
return any
def __changes(self):
for key, value in self.__init_vars.items():
if key is '_Switch__init_vars':
continue
elif key not in vars(self) or getattr(self, key) != value:
yield (key, dict(previous=value, current=getattr(self, key)))
def __signal_and_return(self, inpt, is_enabled):
if is_enabled:
signals.switch_active.call(self, inpt)
return is_enabled
[docs]class Condition(object):
"""
A Condition is the configuration of an argument, its attribute and an
operator. It tells you if it itself is true or false given an input.
The ``argument`` defines what this condition is checking. Perhaps it's a
``User`` or ``Request`` object. The ``attribute`` name is then extracted out
of an instance of the argument to produce a variable. That variable is then
compared to the operator to determine if the condition applies to the input
or not.
For example, for the request IP address, you would define a ``Request``
argument, that had an ``ip`` property. A condition would then be constrcted
like so:
from myapp.gutter import RequestArgument
from gutter.client.models import Condition
>> condition = Condition(argument=RequestArgument, attribute='ip', operator=some_operator)
When the Condition is called, it is passed the input. The argument is then
called (constructed) with input object to produce an instance. The
attribute is then extracted from that instance to produce the variable.
The extacted variable is then checked against the operator.
To put it another way, say you wanted a condition to only allow your switch
to people between 15 and 30 years old. To make the condition:
1. You would create a ``UserArgument`` class that takes a user object in
its constructor. The class also has an ``age`` method which returns
the user object's age.
2. You would then create a new Condition via:
``Condition(argument=UserInput, attriibute='age', operator=Between(15, 30))``.
3. You then call that condition with a ``User``, and it would return
``True`` if the age of the user the ``UserArgument`` instance wraps
is between 15 and 30.
"""
def __init__(self, argument, attribute, operator, negative=False):
self.attribute = attribute
self.argument = argument
self.operator = operator
self.negative = negative
def __repr__(self):
argument = ".".join((self.argument.__name__, self.attribute))
return '<Condition "%s" %s>' % (argument, self.operator)
def __eq__(self, other):
return (
self.argument == other.argument and
self.attribute == other.attribute and
self.operator == other.operator and
self.negative is other.negative
)
[docs] def call(self, inpt):
"""
Returns if the condition applies to the ``inpt``.
If the class ``inpt`` is an instance of is not the same class as the
condition's own ``argument``, then ``False`` is returned. This also
applies to the ``NONE`` input.
Otherwise, ``argument`` is called, with ``inpt`` as the instance and
the value is compared to the ``operator`` and the Value is returned. If
the condition is ``negative``, then then ``not`` the value is returned.
Keyword Arguments:
inpt -- An instance of the ``Input`` class.
"""
if inpt is Manager.NONE_INPUT:
return False
# Call (construct) the argument with the input object
argument_instance = self.argument(inpt)
if not argument_instance.applies:
return False
application = self.__apply(argument_instance, inpt)
if self.negative:
application = not application
return application
@property
def argument_string(self):
parts = [self.argument.__name__, self.attribute]
return '.'.join(map(str, parts))
def __str__(self):
return "%s %s" % (self.argument_string, self.operator)
def __apply(self, argument_instance, inpt):
variable = getattr(argument_instance, self.attribute)
try:
return self.operator.applies_to(variable)
except Exception as error:
signals.condition_apply_error.call(self, inpt, error)
return False
[docs]class Manager(threading.local):
"""
The Manager holds all state for Gutter. It knows what Switches have been
registered, and also what Input objects are currently being applied. It
also offers an ``active`` method to ask it if a given switch name is
active, given its conditions and current inputs.
"""
key_separator = ':'
namespace_separator = '.'
default_namespace = ['default']
#: Special singleton used to represent a "no input" which arguments can look
#: for and ignore
NONE_INPUT = object()
def __init__(self, storage, autocreate=False, switch_class=Switch,
inputs=None, namespace=None):
if inputs is None:
inputs = []
if namespace is None:
namespace = self.default_namespace
elif isinstance(namespace, basestring):
namespace = [namespace]
self.storage = storage
self.autocreate = autocreate
self.inputs = inputs
self.switch_class = switch_class
self.namespace = namespace
def __getstate__(self):
inner_dict = vars(self).copy()
inner_dict.pop('inputs', False)
inner_dict.pop('storage', False)
return inner_dict
def __getitem__(self, key):
return self.switch(key)
@property
[docs] def switches(self):
"""
List of all switches currently registered.
"""
results = [
switch for name, switch in self.storage.iteritems()
if name.startswith(self.__joined_namespace)
]
return results
[docs] def switch(self, name):
"""
Returns the switch with the provided ``name``.
If ``autocreate`` is set to ``True`` and no switch with that name
exists, a ``DISABLED`` switch will be with that name.
Keyword Arguments:
name -- A name of a switch.
"""
try:
switch = self.storage[self.__namespaced(name)]
except KeyError:
if not self.autocreate:
raise ValueError("No switch named '%s' registered" % name)
switch = self.__create_and_register_disabled_switch(name)
switch.manager = self
return switch
[docs] def register(self, switch, signal=signals.switch_registered):
'''
Register a switch and persist it to the storage.
'''
if not switch.name:
raise ValueError('Switch name cannot be blank')
self.__persist(switch)
self.__sync_parental_relationships(switch)
switch.manager = self
signal.call(switch)
def unregister(self, switch_or_name):
name = getattr(switch_or_name, 'name', switch_or_name)
switch = self.switch(name)
[self.unregister(child) for child in switch.children]
del self.storage[self.__namespaced(name)]
signals.switch_unregistered.call(switch)
def input(self, *inputs):
self.inputs = list(inputs)
def flush(self):
self.inputs = []
def active(self, name, *inputs, **kwargs):
switch = self.switch(name)
if not kwargs.get('exclusive', False):
inputs = tuple(self.inputs) + inputs
# Also check the switches against "NONE" input. This ensures there will
# be at least one input checked.
if not inputs:
inputs = (self.NONE_INPUT,)
# If necessary, the switch first concents with its parent and returns
# false if the switch is conceting and the parent is not enabled for
# ``inputs``.
if switch.concent and switch.parent and not self.active(switch.parent, *inputs, **kwargs):
return False
return any(map(switch.enabled_for, inputs))
def update(self, switch):
self.register(switch, signal=signals.switch_updated)
switch.reset()
# If this switch has any children, it's likely their instance of this
# switch (their ``parent``) is now "stale" since this switch has
# been updated. In order for them to pick up their new parent, we need
# to re-save them.
#
# ``register`` is not used here since we do not need/want to sync
# parental relationships.
for child in getattr(switch, 'children', []):
child = self.storage[self.__namespaced(child)]
child.parent = switch.name
self.__persist(child)
def namespaced(self, namespace):
new_namespace = []
# Only start with the current namesapce if it's not the default
# namespace
if self.namespace is not self.default_namespace:
new_namespace = list(self.namespace)
new_namespace.append(namespace)
return type(self)(
storage=self.storage,
autocreate=self.autocreate,
inputs=self.inputs,
switch_class=self.switch_class,
namespace=new_namespace,
)
def __persist(self, switch):
self.storage[self.__namespaced(switch.name)] = switch
return switch
def __create_and_register_disabled_switch(self, name):
switch = self.switch_class(name)
switch.state = self.switch_class.states.DISABLED
self.register(switch)
return switch
def __sync_parental_relationships(self, switch):
new_parent = None
try:
new_parent = self.switch(self.__parent_key_for(switch))
switch.parent = new_parent.name
new_parent.children.append(switch.name)
new_parent.save()
except ValueError:
# no parent found or created, so we just pass
pass
def __parent_key_for(self, switch):
# TODO: Make this a method on the switch object
parent_parts = switch.name.split(self.key_separator)[:-1]
return self.key_separator.join(parent_parts)
def __namespaced(self, name=''):
if not self.__joined_namespace:
return name
else:
return self.namespace_separator.join(
(self.__joined_namespace, name)
)
@property
def __joined_namespace(self):
return self.namespace_separator.join(self.namespace)