From 7a2bc91682850b3340fd341f2abac28622515706 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Mon, 30 Nov 2015 16:31:34 +0000 Subject: [PATCH] Add initial attempt at env-var based feature specification (and tests). --- requirements.txt | 1 + waffle/__init__.py | 82 ++++++++++++++++++++++ waffle/tests/test_waffle.py | 135 ++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/requirements.txt b/requirements.txt index d9c6301d..c5d8ef62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # These are required to run the tests. Django +django_jinja flake8 # Everything else is in a Django-version-free version diff --git a/waffle/__init__.py b/waffle/__init__.py index 7a151c95..49cba84d 100644 --- a/waffle/__init__.py +++ b/waffle/__init__.py @@ -2,6 +2,7 @@ from decimal import Decimal import random +import os from waffle.utils import get_setting, keyfmt @@ -10,6 +11,37 @@ __version__ = '.'.join(map(str, VERSION)) +def parse_env_vars(var_name): + """ + Takes the name of an environment variable that should be expressed as a + comma separated list of items. Returns the equivalent Python data + structure. + """ + value = os.getenv(var_name, '') + if value: + return [x for x in value.split(',') if x] + return [] + +""" +We default the settings derived from environment vars so the app can't break +when it's started because we've forgotton to configure something. Rather, it'll +just assume an 'all flags off' policy. + +There are three arbitrary buckets: ALPHA, BETA and ALL. ALPHA and BETA have +named users associated with them and flags set for each bucket will only be +active for those users. ALL related flags apply to all users. +""" +USE_ENV_VARS = os.getenv('WAFFLE_USE_ENV_VARS', False) +if USE_ENV_VARS: + ALPHA_USERS = parse_env_vars('WAFFLE_ALPHA_USERS') + BETA_USERS = parse_env_vars('WAFFLE_BETA_USERS') + ALPHA_FLAGS = parse_env_vars('WAFFLE_ALPHA_FLAGS') + BETA_FLAGS = parse_env_vars('WAFFLE_BETA_FLAGS') + ALL_FLAGS = parse_env_vars('WAFFLE_ALL_FLAGS') + SWITCHES = parse_env_vars('WAFFLE_SWITCHES') + SAMPLES = parse_env_vars('WAFFLE_SAMPLES') + + class DoesNotExist(object): """The record does not exist.""" @property @@ -25,6 +57,56 @@ def set_flag(request, flag_name, active=True, session_only=False): def flag_is_active(request, flag_name): + """ + Given the context of the request, indicate if the flag identified by the + flag_name is active. + + This wraps two functions that use either the "normal" database method or + one based upon environment variables. + """ + if USE_ENV_VARS: + return flag_is_active_from_env(request, flag_name) + return flag_is_active_from_database(request, flag_name) + + +def flag_is_active_from_env(request, flag_name): + """ + A flag_name indicates that a referenced feature is active. The boolean + response depends on several things: + + * The flag group the flag_name is found in. + * The membership of associated user buckets for the requesting user. + + There are three flag groups: ALPHA, BETA and ALL. The ALPHA and BETA flag + groups refer to associated user buckets. ALL flags are applied to all + users. + + Ergo, flags are explicitly set for ALPHA and BETA but flags set in ALL are + universal. For example, a flag called 'foo' set in the ALPHA_FLAGS group + will only be seen by members of the ALPHA_USERS bucket. However, if a flag + 'bar' is set in the ALL_FLAGS group ALL users will see it. + """ + # Check there are flags set via the environment variables. + if not (ALPHA_FLAGS or BETA_FLAGS or ALL_FLAGS): + return False + # If the flag is an ALL flag, it's on for everyone! + if ALL_FLAGS and flag_name in ALL_FLAGS: + return True + # User may not be logged in. + if hasattr(request, 'user'): + username = request.user.username + # Arbitrary decision that ALPHA takes precedence. + if ALPHA_FLAGS and flag_name in ALPHA_FLAGS: + return username in ALPHA_USERS + elif BETA_FLAGS and flag_name in BETA_FLAGS: + return username in BETA_USERS + return False + + +def flag_is_active_from_database(request, flag_name): + """ + A regular waffle. + """ from .models import cache_flag, Flag from .compat import cache diff --git a/waffle/tests/test_waffle.py b/waffle/tests/test_waffle.py index be1828c7..8460da0b 100644 --- a/waffle/tests/test_waffle.py +++ b/waffle/tests/test_waffle.py @@ -282,6 +282,141 @@ def test_set_then_unset_testing_flag(self): self.assertEqual(b'on', response.content) +class EnvVarTests(TestCase): + """ + Tests related to configuration derived from environment variables. + """ + + def setUp(cls): + setattr(waffle, 'USE_ENV_VARS', True) + setattr(waffle, 'ALPHA_USERS', []) + setattr(waffle, 'BETA_USERS', []) + setattr(waffle, 'ALPHA_FLAGS', []) + setattr(waffle, 'BETA_FLAGS', []) + setattr(waffle, 'ALL_FLAGS', []) + + @classmethod + def tearDownClass(cls): + setattr(waffle, 'USE_ENV_VARS', False) + + def test_parse_env_vars(self): + """ + A comma-separated list of items is turned into a ordered list. + """ + with mock.patch('os.getenv', return_value='foo,bar,baz') as patch: + actual = waffle.parse_env_vars('foo') + expected = ['foo', 'bar', 'baz'] + assert expected == actual + + def test_parse_env_vars_malformed(self): + """ + A malformed comma-separated list is "mended" appropriately (empty + entries are ignored). + """ + with mock.patch('os.getenv', return_value='foo,,baz') as patch: + actual = waffle.parse_env_vars('foo') + expected = ['foo', 'baz'] + assert expected == actual + + def test_parse_env_vars_nonexistent(self): + """ + A none-existent environment variable will return an empty list. + """ + with mock.patch('os.getenv', return_value=None) as patch: + actual = waffle.parse_env_vars('foo') + expected = [] + assert expected == actual + + def test_use_env_vars_code_branch(self): + """ + Ensure the expected code branch is used when USE_ENV_VARS is truthy. + """ + with mock.patch('waffle.flag_is_active_from_env', + return_value=False) as patch: + request = get() + assert not waffle.flag_is_active(request, 'foo') + patch.assert_called_once_with(request, 'foo') + + def test_no_flags_set(self): + """ + ALPHA, BETA and ALL are empty. Must return False + """ + setattr(waffle, 'ALPHA_FLAGS', []) + setattr(waffle, 'BETA_FLAGS', []) + setattr(waffle, 'ALL_FLAGS', []) + request = get() + assert not waffle.flag_is_active(request, 'foo') + + def test_all_flags_matched(self): + """ + The flag has been found in ALL_FLAGS. Must return True. + """ + setattr(waffle, 'ALL_FLAGS', ['foo', ]) + request = get() + assert waffle.flag_is_active(request, 'foo') + + def test_flag_in_alpha_not_alpha_user(self): + """ + The referenced flag is in the ALPHA bucket but the requesting user + isn't an ALPHA_USER. Must return False. + """ + setattr(waffle, 'ALPHA_FLAGS', ['foo', ]) + request = get() + assert not waffle.flag_is_active(request, 'foo') + + def test_flag_in_alpha_with_alpha_user(self): + """ + The referenced flag is in the ALPHA bucket AND the requesting user is + an ALPHA_USER. Must return True. + """ + setattr(waffle, 'ALPHA_FLAGS', ['foo', ]) + setattr(waffle, 'ALPHA_USERS', ['bar', ]) + request = get() + request.user.username = 'bar' + assert waffle.flag_is_active(request, 'foo') + + def test_flag_in_beta_not_beta_user(self): + """ + The referenced flag is in the BETA bucket but the requesting user + isn't a BETA_USER. Must return False. + """ + setattr(waffle, 'BETA_FLAGS', ['foo', ]) + request = get() + assert not waffle.flag_is_active(request, 'foo') + + def test_flag_in_beta_with_beta_user(self): + """ + The referenced flag is in the BETA bucket AND the requesting user is + a BETA_USER. Must return True. + """ + setattr(waffle, 'BETA_FLAGS', ['foo', ]) + setattr(waffle, 'BETA_USERS', ['bar', ]) + request = get() + request.user.username = 'bar' + assert waffle.flag_is_active(request, 'foo') + + def test_flag_does_not_match_existing_flags(self): + """ + There are flags in all the *_FLAGS buckets but the referenced flag does + not match. Must return False. + """ + setattr(waffle, 'ALL_FLAGS', ['foo', ]) + setattr(waffle, 'ALPHA_FLAGS', ['bar', ]) + setattr(waffle, 'BETA_FLAGS', ['baz', ]) + request = get() + assert not waffle.flag_is_active(request, 'qux') + + def test_flag_no_user(self): + """ + If there is no user to match into the ALPHA and BETA buckets then + return False. + """ + setattr(waffle, 'ALPHA_FLAGS', ['foo', ]) + request = get() + delattr(request, 'user') + assert not waffle.flag_is_active(request, 'foo') + + class SwitchTests(TestCase): def test_switch_active(self): switch = Switch.objects.create(name='myswitch', active=True)