Skip to content

Instantly share code, notes, and snippets.

@chrislawlor
Created February 27, 2019 16:46
Show Gist options
  • Save chrislawlor/6cf71bf6f656b4b64441ece8ed873f5b to your computer and use it in GitHub Desktop.
Save chrislawlor/6cf71bf6f656b4b64441ece8ed873f5b to your computer and use it in GitHub Desktop.
Simple Feature Toggles
"""
This gist is the "guts" of a simple feature toggle implementation.
Toggles are created ad-hoc in application code by instantiating a
``Toggle``, like this:
from toggle import Toggle
my_toggle = Toggle('feature_name')
Behind the scenes, this automatically creates a ``StoredToggle``
instance in the database, if it does not already exist. The
``StoredToggle`` holds the actual value of the toggle, and
defaults to ``False``, so new toggles are off by default.
Toggles can be optionally grouped into a ``Release``, which
mainly provides the ability to enable or disable a set of
toggles together.
Toggle values are pro-actively stored in cache via the Django
cache API. Toggle values are managed in the Django admin, and
cached values are automatically updated as needed.
For testing, a context manager is available, which can set the
value for any arbitrary toggle. This updates the cached value
for the duration of the manager, so it does require the use
of a "real" cache backend (local memory is fine, but dummy
cache is not.
Example:
# test_feature.py
from toggle.utils import toggle_as
with toggle_as('my_feature', True):
# test where the feature is enabled
with toggle_as('my_feature', False):
# test where the feature is disabled
"""
# toggle.py
class Toggle(object):
"""
Thin wrapper around the StoredToggle model, which lazily loads
toggle states as they are checked, and automatically creates new
StoredToggles as new checks are added.
A ``Toggle`` is persisted to the database by the ``StoredToggle`` model.
Instantiate a ``Toggle`` by name. A ``Toggles`` value is checked on
demand, not on instantiation, so database loads are deferred. If a
``Toggle`` is instantatied with an unknown name, a new ``StoredToggle``
will be created automatically (state will default to ``False``).
``Toggle`` state is also stored to cache via the Django cache API, so
subsequent state checks are fast and cheap. If multiple caches are
available, the desired cache can be specified by name via the
``TOGGLE_CACHE`` setting.
Notably, toggle state is NOT stored in the ``Toggle`` instance, so it is
possible to instantiate ``Toggle``s on demand. Still, the recommended
usage is to treat instantated ``Toggle``s as constants - create them once
and use them forever.
Usage:
# my_module.py
my_toggle = Toggle('my.flag.name')
if my_toggle:
# do something
"""
def __init__(self, name):
# type: (str) -> None
self.name = name
def __repr__(self):
# type: () -> str
return "<Toggle %s>" % self.name
def __bool__(self):
# type: () -> bool
state = cache.get(self.name)
if state is None:
stored = self.load_stored_toggle()
stored.update_cache()
state = stored.state
return state
def load_stored_toggle(self):
# type: () -> StoredToggle
stored, created = StoredToggle.objects.get_or_create(name=self.name)
if created:
logger.info("Created new StoredToggle: %s" % self.name)
return stored
def __nonzero__(self):
# type: () -> bool
# Python 2 compat
return self.__bool__()
def warm_cache():
"""
Pre-populates the cache with all toggle states. Call this at application
startup, if desired.
"""
Toggle.objects.filter(state=True).bulk_update_cache(True)
Toggle.objects.filter(state=False).bulk_update_cache(False)
# utils.py
@contextmanager
def toggle_as(name, value):
# type: (Text, bool) -> None
"""
Set the value of the specified toggle for a limited duration. Intended
for use in unit tests.
This context manager modifies the cache, but not the underlying
database. Therefore, it will essentially reduce to a no-op
if a dummy cache is used.
Usage:
with toggle_as('my_toggle', False):
# test something
"""
toggle = Toggle(name)
old_value = bool(toggle)
st = StoredToggle(name=name)
st.state = value
st.update_cache()
try:
yield None
finally:
st.state = old_value
st.update_cache()
# models.py
class Release(TimestampedModel):
"""
Logical groups of toggles.
"""
name = models.CharField(max_length=100)
def toggle_counts(self):
# type: () -> Tuple[int, int]
enabled = self.toggles.filter(state=True).count()
total = self.toggles.count()
return enabled, total
def toggle_status_description(self):
# type: () -> str
enabled, total = self.toggle_counts()
return "%s of %s" % (enabled, total)
def __unicode__(self):
# type: () -> str
return self.name
class StoredToggleQuerySet(models.QuerySet):
"""
Allows use of .update() to update the state of multiple toggles,
while keeping the cache updated.
"""
def bulk_update_cache(self, state):
# type: (bool) -> Iterable[str]
"""
Synchronize the cache with the state of all ``Toggle``s in this
QuerySet.
"""
query = self.all()
toggle_names = query.values_list("name", flat=True)
values = {CACHE_PATTERN % name: state for name in toggle_names}
cache.set_many(values)
return toggle_names
def update(self, state=None, **kwargs):
# type: (Optional[bool], **Any) -> int
if state is not None:
kwargs['state'] = state
rows = super(StoredToggleQuerySet, self).update(**kwargs)
if state:
names = self.bulk_update_cache(state)
bulk_update.send(sender=self.model.__class__, names=names, state=state)
return rows
update.alters_data = True # type: ignore
class StoredToggle(TimestampedModel):
"""
Persistent toggle storage. Client code doesn't use this class directly,
use the ``Toggle`` wrapper instead.
"""
class Meta:
verbose_name_plural = "Toggles"
ordering = ('name',)
release = models.ForeignKey(
Release,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="toggles",
)
name = models.SlugField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, null=True)
state = models.BooleanField(default=False)
objects = StoredToggleQuerySet.as_manager()
def get_state_display(self):
# type: () -> str
return "ON" if self.state else "OFF"
def __repr__(self):
# type: () -> str
return "%s: %s" % (self.name, self.get_state_display())
def __unicode__(self):
# type: () -> str
return self.name
@property
def cache_key(self):
# type: () -> str
return CACHE_PATTERN % self.name
def update_cache(self):
cache.set(self.cache_key, self.state)
def save(self, *args, **kwargs):
super(StoredToggle, self).save(*args, **kwargs)
self.update_cache()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment