Fully featured Python feature switch toolkit
.. image:: https://api.travis-ci.org/disqus/gutter.png?branch=master :target: http://travis-ci.org/disqus/gutter
NOTE: This repo is the client for Gargoyle 2, known as "Gutter". It does not work with the existing
Gargoyle 1 codebase_.
Gutter is feature switch management library. It allows users to create feature switches and setup conditions those switches will be enabled for. Once configured, switches can then be checked against inputs (requests, user objects, etc) to see if the switches are active.
For a UI to configure Gutter with see the
gutter-django project_
Switches_
Conditions_
Checking Switches as Active_
Testing Utilities_
Gutter requires a small bit of configuration before usage.
Choosing Storage ~~~~~~~~~~~~~~~~
Switches are persisted in a
storageobject, which is a
dictor any object which provides the
types.MappingTypeinterface (
__setitem__and
__getitem__methods). By default,
gutteruses an instance of
MemoryDictfrom the
durabledict library_. This engine does not persist data once the process ends so a more persistent data store should be used.
Autocreate ~~~~~~~~~~
guttercan also "autocreate" switches. If
autocreateis enabled, and
gutteris asked if the switch is active but the switch has not been created yet,
gutterwill create the switch automatically. When autocreated, a switch's state is set to "disabled."
This behavior is off by default, but can be enabled through a setting. More on "settings" below.
Configuring Settings ~~~~~~~~~~~~~~~~~~~~
To change the
storageand/or
autocreatesettings, simply import the settings module and set the appropriate variables:
.. code:: python
from gutter.client.settings import manager as manager_settings from durabledict.dict import RedisDict from redis import RedisClientmanager_settings.storage_engine = RedisDict('gutter', RedisClient())) manager_settings.autocreate = True
In this case, we are changing the engine to durabledict's
RedisDictand turning on
autocreate. These settings will then apply to all newly constructed
Managerinstances. More on what a
Manageris and how you use it later in this document.
Once the
Manager's storage engine has been configured, you can import gutter's default
Managerobject, which is your main interface with
gutter:
.. code:: python
from gutter.client.default import gutter
At this point the
gutterobject is an instance of the
Managerclass, which holds all methods to register switches and check if they are active. In most installations and usage scenarios, the
gutter.client.guttermanager will be your main interface.
Using a different default Manager ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you would like to construct and use a different default manager, but still have it accessible via
gutter.client.gutter, you can construct and then assign a
Managerinstance to
settings.manager.defaultvalue:
.. code:: python
from gutter.client.settings import manager as manager_settings from gutter.client.models import Managermanager_settings.default = Manager({}) # Must be done before importing the default manager
from gutter.client.default import gutter
assert manager_settings.default is gutter
.. WARNING::
:warning::warning: Note that the
settings.manager.defaultvalue must be set before importing the default
gutterinstance. :warning::warning:
The first step in your usage of
guttershould be to define your arguments that you will be checking switches against. An "argument" is an object which understands the business logic and object in your system (users, requests, etc) and knows how to validate, transform and extract variables from those business objects for
Switchconditions. For instance, your system may have a
Userobject that has properties like
is_admin,
date_joined, etc. To switch against it, you would then create arguments for each of those values.
To do that, you construct a class which inherits from
gutter.client.arguments.Container. Inside the body of the class, you create as many class variable "arguments" that you need by using the
gutter.client.argumentsfunction.
.. code:: python
from gutter.client import argumentsfrom myapp import User
class UserArguments(arguments.Container):
COMPATIBLE_TYPE = User name = arguments.String(lambda self: self.input.name) is_admin = arguments.Boolean(lambda self: self.input.is_admin) age = arguments.Value(lambda self: self.input.age)
There are a few things going on here, so let's break down what they all mean.
UserArgumentclass is subclassed from
Container. The subclassing is required since
Containerimplements some of the required API.
arguments.TYPE, where
TYPEis the type of variable this argument is. At present there are 3 types:
Valuefor general values,
Booleanfor boolean values and
Stringfor string values.
arguments.TYPE()is called with a callable that returns the value. In the above example, we'll want to make some switches active based on a user's
name,
is_adminstatus and
age.
self.input, which is the input object (in this case a
Userinstance).
Variableobjects understand
Switchconditions and operators, and implement the correct API to allow themselves to be appropriately compared.
COMPATIBLE_TYPEdeclares that this argument only works with
Userinstances. This works with the default implementation of
appliesin the base argument that checks if the
typeof the input is the same as
COMPATIBLE_TYPE.
Since constructing arguments that simply reference an attribute on
self.inputis so common, if you pass a string as the first argument of
argument(), when the argument is accessed, it will simply return that property from
self.input. You must also pass a
Variableto the
variable=kwarg so gutter know what Variable to wrap your value in.
.. code:: python
from gutter.client import argumentsfrom myapp import User
class UserArguments(Container):
COMPATIBLE_TYPE = User name = arguments.String('name') is_admin = arguments.Boolean('is_admin') age = arguments.Value('age')
Rationale for Arguments ~~~~~~~~~~~~~~~~~~~~~~~
You might be asking, why have these
Argumentobjects at all? They seem to just wrap an object in my system and provide the same API. Why can't I just use my business object itself and compare it against my switch conditions?
The short answer is that
Argumentobjects provide a translation layer to translate your business objects into objects that
gutterunderstands. This is important for a couple reasons.
First, it means you don't clutter your business logic/objects with code to support
gutter. You declare all the arguments you wish to provide to switches in one location (an Argument) whose single responsibility it to interface with
gutter. You can also construct more savvy Argument objects that may be the combination of multiple business objects, consult 3rd party services, etc. All still not cluttering your main application code or business objects.
Secondly, and most importantly, Arguments return
Variableobjects, which ensure
gutterconditions work correctly. This is mostly relevant to the percentage-based operators, and is best illustrated with an example.
Imagine you have a
Userclass with an
is_vipboolean field. Let's say you wanted to turn on a feature for only 10% of your VIP customers. To do that, you would write a condition that says, "10% of the time when I'm called with the variable, I should be true." That line of code would probably do something like this:
.. code:: python
return 0 <= (hash(variable) % 100) < 10
The issue is that if
variable = True, then
hash(variable) % 100will always be the same value for every
Userwith
is_vipof
True:
.. code:: python
>>> hash(True) 1 >>> hash(True) % 100 1
This is because in Python
Trueobjects always have the same hash value, and thus the percentage check doesn't work. This is not the behavior you want.
For the 10% percentage range, you want it to be active for 10% of the inputs. Therefore, each input must have a unique hash value, exactly the feature the
Booleanvariable provides. Every
Variablehas known characteristics against conditions, while your objects may not.
That said, you don't absolutely have to use
Variableobjects. For obvious cases, like
use.age > some_valueyour
Userinstance will work just fine, but to play it safe you should use
Variableobjects. Using
Variableobjects also ensure that if you update
gutterany new
Operatortypes that are added will work correctly with your
Variables.
Switches encapsulate the concept of an item that is either 'on' or 'off' depending on the input. The swich determines its on/off status by checking each of its
conditionsand seeing if it applies to a certain input.
Switches are constructed with only one required argument, a
name:
.. code:: python
from gutter.client.models import Switchswitch = Switch('my cool feature')
Switches can be in 3 core states:
GLOBAL,
DISABLEDand
SELECTIVE. In the
GLOBALstate, the Switch is enabled for every input no matter what.
DISABLEDSwitches are not disabled for any input, no matter what.
SELECTIVESwitches enabled based on their conditions.
Switches can be constructed in a certain state or the property can be changed later:
.. code:: python
switch = Switch('new feature', state=Switch.states.DISABLED) another_switch = Switch('new feature') another_switch.state = Switch.states.DISABLED
Compounded ~~~~~~~~~~
When in the
SELECTIVEstate, normally only one condition needs be true for the Switch to be enabled for a particular input. If
switch.compoundedis set to
True, then all of the switches conditions need to be true in order to be enabled::
switch = Switch('require alll conditions', compounded=True)
Heriarchical Switches ~~~~~~~~~~~~~~~~~~~~~
You can create switches using a specific hierarchical naming scheme. Switch namespaces are divided by the colon character (":"), and hierarchies of switches can be constructed in this fashion:
.. code:: python
parent = Switch('movies') child1 = Switch('movies:star_wars') child2 = Switch('movies:die_hard') grandchild = Switch('movies:star_wars:a_new_hope')
In the above example, the
child1switch is a child of the
"movies"switch because it has
movies:as a prefix to the switch name. Both
child1and
child2are "children of the parent
parentswitch. And
grandchildis a child of the
child1switch, but not the
child2switch.
Concent ~~~~~~~
By default, each switch makes its "am I active?" decision independent of other switches in the Manager (including its parent), and only consults its own conditions to check if it is enabled for the input. However, this is not always the case. Perhaps you have a cool new feature that is only available to a certain class of user. And of those users, you want 10% to be be exposed to a different user interface to see how they behave vs the other 90%.
gutterallows you to set a
concentflag on a switch that instructs it to check its parental switch first, before checking itself. If it checks its parent and it is not enabled for the same input, the switch immediately returns
False. If its parent is enabled for the input, then the switch will continue and check its own conditions, returning as it would normally.
For example:
.. code:: python
parent = Switch('cool_new_feature') child = Switch('cool_new_feature:new_ui', concent=True)
For example, because
childwas constructed with
concent=True, even if
childis enabled for an input, it will only return
Trueif
parentis also enabled for that same input.
Note: Even switches in a
GLOBALor
DISABLEDstate (see "Switch" section above) still consent their parent before checking themselves. That means that even if a particular switch is
GLOBAL, if it has
concentset to
Trueand its parent is not enabled for the input, the switch itself will return
False.
Registering a Switch ~~~~~~~~~~~~~~~~~~~~
Once your
Switchis constructed with the right conditions, you need to register it with a
Managerinstance to preserve it for future use. Otherwise it will only exist in memory for the current process. Register a switch via the
registermethod on a
Managerinstance:
.. code:: python
gutter.register(switch)
The Switch is now stored in the Manager's storage and can be checked if active through
gutter.active(switch).
Updating a Switch ~~~~~~~~~~~~~~~~~
If you need to update your Switch, simply make the changes to the
Switchobject, then call the
Manager's
update()method with the switch to tell it to update the switch with the new object:
.. code:: python
switch = Switch('cool switch') manager.register(switch)switch.name = 'even cooler switch' # Switch has not been updated in manager yet
manager.update(switch) # Switch is now updated in the manager
Since this is a common pattern (retrieve switch from the manager, then update it), gutter provides a shorthand API in which you ask the manager for a switch by name, and then call
save()on the switch to update it in the
Managerit was retreived from:
.. code:: python
switch = manager.switch('existing switch') switch.name = 'a new name' # Switch is not updated in manager yet switch.save() # Same as calling manager.update(switch)
Unregistering a Switch ~~~~~~~~~~~~~~~~~~~~~~
Existing switches may be removed from the Manager by calling
unregister()with the switch name or switch instance:
.. code:: python
gutter.unregister('deprecated switch') gutter.unregister(a_switch_instance)
Note: If the switch is part of a hierarchy and has children switches (see the "Hierarchical Switches" section above), all descendent switches (children, grandchildren, etc) will also be unregistered and deleted.
Each Switch can have 0+ conditions, which describe the conditions under which that switch is active.
Conditionobjects are constructed with three values: a
argument,
attributeand
operator.
An
argumentis any
Argumentclass, like the one you defined earlier. From the previous example,
UserArgumentis an argument object.
attributeis the attribute on a argument instance that you want this condition to check.
operatoris some sort of check applied against that attribute. For instance, is the
UserArgument.agegreater than some value? Equal to some value? Within a range of values? Etc.
Let's say you wanted a
Conditionthat checks if the user's age is > 65 years old? You would construct a Condition that way:
.. code:: python
from gutter.client.operators.comparable import MoreThancondition = Condition(argument=UserArgument, attribute='age', operator=MoreThan(65))
This Condition will be true if any input instance has an
agethat is more than
65.
Please see the
gutter.operatorsfor a list of available operators.
Conditions can also be constructed with a
negativeargument, which negates the condition. For example:
.. code:: python
from gutter.client.operators.comparable import MoreThancondition = Condition(argument=UserArgument, attribute='age', operator=MoreThan(65), negative=True)
This Condition is now
Trueif the condition evaluates to
False. In this case if the user's
ageis not more than
65.
Conditions then need to be appended to a switch instance like so:
.. code:: python
switch.conditions.append(condition)
You can append as many conditions as you would like to a switch, there is no limit.
As stated before, switches are checked against input objects. To do this, you would call the switch's
enabled_for()method with a
Userinstance, for instance. You may call
enabled_for()with any input object, it will ignore inputs for which it knows nothing about. If the
Switchis active for your input,
enabled_forwill return
True. Otherwise, it will return
False.
gutter.active()API ~~~~~~~~~~~~~~~~~~~~~~~~~
A common use case of gutter is to use it during the processing of a web request. During execution of code, different code paths are taken depending on if certain switches are active or not. Often times there are multiple switches in existence at any one time and they all need to be checked against multiple arguments. To handle this use case, Gutter provides a higher-level API.
To check if a
Switchis active, simply call
gutter.active()with the Switch name:
.. code:: python
gutter.active('my cool feature') >>> True
The switch is checked against some number of input objects. Inputs can be added to the
active()check one of two ways: locally, passed in to the
active()call or globally, configured ahead of time.
To check against local inputs,
active()takes any number of input objects after the switch name to check the switch against. In this example, the switch named
'my cool feature'is checked against input objects
input1and
input2:
.. code:: python
gutter.active('my cool feature', input1, input2) >>> True
If you have global input objects you would like to use for every check, you can set them up by calling the Manager's
input()method:
.. code:: python
gutter.input(input1, input2)
Now,
input1and
input2are checked against for every
activecall. For example, assuming
input1and
input2are configured as above, this
active()call would check if the Switch was enabled for inputs
input1,
input2and
input3in that order::
gutter.active('my cool feature', input3)
Once you're doing using global inputs, perhaps at the end of a request, you should call the Manager's
flush()method to remove all the inputs:
.. code:: python
gutter.flush()
The Manager is now setup and ready for its next set of inputs.
When calling
active()with a local inputs, you can skip checking the
Switchagainst the global inputs and only check against your locally passed in inputs by passing
exclusive=Trueas a keyword argument to
active():
.. code:: python
gutter.input(input1, input2) gutter.active('my cool feature', input3, exclusive=True)
In the above example, since
exclusive=Trueis passed, the switch named
'my cool feature'is only checked against
input3, and not
input1or
input2. The
exclusive=Trueargument is not persistent, so the next call to
active()without
exclusive=Truewill again use the globally defined inputs.
Gutter provides 4 total signals to connect to: 3 about changes to Switches, and 1 about errors applying Conditions. They are all available from the
gutter.signalsmodule
Switch Signals ~~~~~~~~~~~~~~ There are 3 signals related to Switch changes:
switch_registered- Called when a new switch is registered with the Manager.
switch_unregistered- Called when a switch is unregistered with the Manager.
switch_updated- Called with a switch was updated.
To use a signal, simply call the signal's
connect()method and pass in a callable object. When the signal is fired, it will call your callable with the switch that is being register/unregistered/updated. I.e.:
.. code:: python
from gutter.client.signals import switch_updateddef log_switch_update(switch): Syslog.log("Switch %s updated" % switch.name)
switch_updated.connect(log_switch_updated)
Understanding Switch Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The
switch_updatedsignal can be connected to in order to be notified when a switch has been changed. To know what changed in the switch, you can consult its
changesproperty:
.. code:: python
>>> from gutter.client.models import Switch >>> switch = Switch('test') >>> switch.concent True >>> switch.concent = False >>> switch.name = 'new name' >>> switch.changes {'concent': {'current': False, 'previous': True}, 'name': {'current': 'new name', 'previous': 'test'}}
As you can see, when we changed the Switch's
concentsetting and
name,
switch.changesreflects that in a dictionary of changed properties. You can also simply ask the switch if anything has changed with the
changedproperty. It returns
Trueor
Falseif the switch has any changes as all.
You can use these values inside your signal callback to make decisions based on what changed. I.e., email out a diff only if the changes include changed conditions.
Condition Application Error Signal ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When a
Switchchecks an input object against its conditions, there is a good possibility that the
Argumentvalue may be some sort of unexpected value, and can cause an exception. Whenever there is an exception raised during
Conditionchecking itself against an input, the
Conditionwill catch that exception and return
False.
While catching all exceptions is generally bad form and hides error, most of the time you do not want to fail an application request just because there was an error checking a switch condition, especially if there was an error during checking a
Conditionfor which a user would not have applied in the first place.
That said, you would still probably want to know if there was an error checking a Condition. To accomplish this,
gutter-client provides a
condition_apply_errorsignal which is called when there was an error checking a
Condition. The signal is called with an instance of the condition, the input which caused the error and the instance of the Exception class itself:
.. code:: python
signals.condition_apply_error.call(condition, inpt, error)
In your connected callback, you can do whatever you would like: log the error, report the exception, etc.
gutterallows the use of "namespaces" to group switches under a single umbrella, while both not letting one namespace see the switches of another namespace, but allowing them to share the same storage instance, operators and other configuration.
Given an existing vanilla
Managerinstance, you can create a namespaced manager by calling the
namespaced()method:
.. code:: python
notifications = gutter.namespaced('notifications')
At this point,
notificationsis a copy of
gutter, inheriting all of its:
autocreatesetting
It does not, however, share the same switches. Newly constructed
Managerinstances are in the
defaultnamespace. When
namespaced()is called,
gutterchanges the manager's namespace to
notifications. Any switches in the previous
defaultnamespace are not visible in the
notificationsnamespace, and vice versa.
This allows you to have separate namespaced "views" of switches, possibly named the exact same name, and not have them conflict with each other.
Gutter features a
@switch_activedecorator you can use to decorate your Django views. When decorated, if the switch named as the first argument of the
@switch_decorateddecorator is False, a
Http404exception is raised. However, if you also pass a
redirect_to=kwarg, the decorator will return a
HttpResponseRedirectinstance, redirecting to that location. If the switch is active, then the view runs as normal.
For example, here is a view decorated with
@switch_active:
.. code:: python
from gutter.client.decorators import switch_active@switch_active('cool_feature') def my_view(request): return 'foo'
As stated above, if the
cool_featureswitch is inactive, this view will raise a
Http404exception.
If, however, the decorator was constructed with a
redirect_to=kwarg:
.. code:: python
@switch_active('cool_feature', redirect_to=reverse('upsell-page'))
Then a
HttpResponseRedirectinstance will be returned, redirecting to
reverse('upsell-page').
If you would like to test code that uses
gutterand have the
guttermanager return predictable results, you can use the
switchesobject from the
testutilsmodule.
The
swtichesobject can be used as both a context manager and a decorator. It is passed
kwargsof switch names and their
activereturn values.
For instance, with this code here, by passing
cool_feature=Trueto the
switchesobject as a context manager, any call to
gutter.active('cool_feature')will return
True. Calls to
active()with other switch names will return their actual live switch status:
.. code:: python
from gutter.client.testutils import switches from gutter.client.default import gutterwith switches(cool_feature=True): gutter.active('cool_feature') # True
And when using
switchesas a decorator:
.. code:: python
from gutter.client.testutils import switches from gutter.client.default import gutter@switches(cool_feature=True) def run(self): gutter.active('cool_feature') # True
Additionally, you may pass an alternate
Managerinstance to
switchesto use that manager instead of the default one:
.. code:: python
from gutter.client.testutils import switches from gutter.client.models import Managermy_manager = Manager({})
@switches(my_manager, cool_feature=True) def run(self): gutter.active('cool_feature') # True