Created
February 27, 2019 16:46
-
-
Save chrislawlor/6cf71bf6f656b4b64441ece8ed873f5b to your computer and use it in GitHub Desktop.
Simple Feature Toggles
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
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