diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 23ed18de..28b48dc0 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -11,6 +11,12 @@ auth.Group: ".. no_pii:": "This model has no PII" auth.Permission: ".. no_pii:": "This model has no PII" +waffle.Flag: + ".. no_pii:": "This model has no PII" +waffle.Sample: + ".. no_pii:": "This model has no PII" +waffle.Switch: + ".. no_pii:": "This model has no PII" auth.User: ".. pii:": "Contains username, password, and email address, must be retired in the consumer of this app" ".. pii_types:" : username, email_address, password diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7d8d65e4..5226c5bf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,9 +11,12 @@ Change Log .. There should always be an "Unreleased" section for changes pending release. -Unreleased -~~~~~~~~~~ +[1.0.0] - 2020-10-13 +~~~~~~~~~~~~~~~~~~~~ +* Fix missing ``module_name`` argument in ``SettingDictToggle`` constructor. +* Extract waffle classes from edx-platform and move them here following ADR `#2 `__, `#3 `__, `#4 `__. This does not introduce backward-incompatible changes, *yet*. +* Fix pinned requirements and incorrect root url that overrode edx-platform's. [0.3.0] - 2020-09-23 ~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/decisions/0004-toggle-api.rst b/docs/decisions/0004-toggle-api.rst index 9c25b10f..af4345f2 100644 --- a/docs/decisions/0004-toggle-api.rst +++ b/docs/decisions/0004-toggle-api.rst @@ -18,7 +18,7 @@ Decision All implementation code should be moved to an ``internal`` module. The Public API will be exposed as follows in ``edx_toggles/toggles/__init__.py``:: - from .internal import ... + from .internal.togglemodule import ... The benefits of this setup include: diff --git a/edx_toggles/__init__.py b/edx_toggles/__init__.py index 6e936e8a..7baedbb7 100644 --- a/edx_toggles/__init__.py +++ b/edx_toggles/__init__.py @@ -2,6 +2,6 @@ Library and utilities for feature toggles. """ -__version__ = '0.3.0' +__version__ = '1.0.0' default_app_config = 'edx_toggles.apps.TogglesConfig' # pylint: disable=invalid-name diff --git a/edx_toggles/templates/edx_toggles/base.html b/edx_toggles/templates/edx_toggles/base.html deleted file mode 100644 index d13a13ed..00000000 --- a/edx_toggles/templates/edx_toggles/base.html +++ /dev/null @@ -1,22 +0,0 @@ - -{% comment %} -As the developer of this package, don't place anything here if you can help it -since this allows developers to have interoperability between your template -structure and their own. - -Example: Developer melding the 2SoD pattern to fit inside with another pattern:: - - {% extends "base.html" %} - {% load static %} - - - {% block extra_js %} - - - {% block javascript %} - - {% endblock javascript %} - - {% endblock extra_js %} -{% endcomment %} - diff --git a/edx_toggles/tests/test_testutils.py b/edx_toggles/tests/test_testutils.py new file mode 100644 index 00000000..7c3dde98 --- /dev/null +++ b/edx_toggles/tests/test_testutils.py @@ -0,0 +1,96 @@ +""" +Tests for waffle utils test utilities. +""" + + +import crum +from django.test import TestCase +from django.test.client import RequestFactory +from edx_django_utils.cache import RequestCache + +from edx_toggles.toggles import WaffleFlag, WaffleFlagNamespace +from edx_toggles.toggles.testutils import override_waffle_flag + + +class OverrideWaffleFlagTests(TestCase): + """ + Tests for the override_waffle_flag decorator/context manager. + """ + + def setUp(self): + super(OverrideWaffleFlagTests, self).setUp() + namespace_name = "test_namespace" + flag_name = "test_flag" + self.namespaced_flag_name = namespace_name + "." + flag_name + self.namespace = WaffleFlagNamespace(namespace_name) + self.waffle_flag = WaffleFlag(self.namespace, flag_name) + + request = RequestFactory().request() + crum.set_current_request(request) + RequestCache.clear_all_namespaces() + + self.addCleanup(crum.set_current_request, None) + self.addCleanup(RequestCache.clear_all_namespaces) + + def temporarily_enable_flag(self): + """ + Temporarily override flag. + """ + + @override_waffle_flag(self.waffle_flag, True) + def test_func(): + """ + Decorated test function. + """ + self.assertTrue(self.waffle_flag.is_enabled()) + + test_func() + + def test_override_waffle_flag_pre_cached(self): + # checks and caches the is_enabled value + self.assertFalse(self.waffle_flag.is_enabled()) + # pylint: disable=protected-access + flag_cache = self.namespace._cached_flags + self.assertIn(self.namespaced_flag_name, flag_cache) + + self.temporarily_enable_flag() + + # test cached flag is restored + self.assertIn(self.namespaced_flag_name, flag_cache) + self.assertFalse(self.waffle_flag.is_enabled()) + + def test_override_waffle_flag_not_pre_cached(self): + # check that the flag is not yet cached + # pylint: disable=protected-access + flag_cache = self.namespace._cached_flags + self.assertNotIn(self.namespaced_flag_name, flag_cache) + + self.temporarily_enable_flag() + + # test cache is removed when no longer using decorator/context manager + self.assertNotIn(self.namespaced_flag_name, flag_cache) + + def test_override_waffle_flag_as_context_manager(self): + self.assertFalse(self.waffle_flag.is_enabled()) + + with override_waffle_flag(self.waffle_flag, True): + self.assertTrue(self.waffle_flag.is_enabled()) + + self.assertFalse(self.waffle_flag.is_enabled()) + + def test_interlocked_overrides(self): + waffle_flag1 = self.waffle_flag + waffle_flag2 = WaffleFlag(self.namespace, waffle_flag1.flag_name + "2") + # pylint: disable=protected-access + self.namespace._cached_flags[waffle_flag2.namespaced_flag_name] = True + + self.assertFalse(waffle_flag1.is_enabled()) + self.assertTrue(waffle_flag2.is_enabled()) + + with override_waffle_flag(waffle_flag1, True): + with override_waffle_flag(waffle_flag2, False): + self.assertTrue(waffle_flag1.is_enabled()) + self.assertFalse(waffle_flag2.is_enabled()) + + self.assertFalse(waffle_flag1.is_enabled()) + self.assertTrue(waffle_flag2.is_enabled()) diff --git a/edx_toggles/tests/test_toggles.py b/edx_toggles/tests/test_toggles.py index 16a9a35c..d48a6b6c 100644 --- a/edx_toggles/tests/test_toggles.py +++ b/edx_toggles/tests/test_toggles.py @@ -12,6 +12,7 @@ class SettingToggleTests(TestCase): """ SettingToggle tests """ + def test_toggle_for_absent_setting(self): toggle1 = toggles.SettingToggle("NAME1", True) toggle2 = toggles.SettingToggle("NAME1", False) @@ -36,9 +37,14 @@ class SettingDictToggleTests(TestCase): """ SettingDictToggle tests """ + def test_toggle_for_absent_setting(self): - toggle1 = toggles.SettingDictToggle("NAME1", "key1", True) - toggle2 = toggles.SettingDictToggle("NAME2", "key2", False) + toggle1 = toggles.SettingDictToggle( + "NAME1", "key1", True, module_name="module1" + ) + toggle2 = toggles.SettingDictToggle( + "NAME2", "key2", False, module_name="module1" + ) self.assertTrue(toggle1.is_enabled()) self.assertFalse(toggle2.is_enabled()) @@ -59,6 +65,7 @@ class ToggleInstancesTests(TestCase): """ Class instance-tracking tests """ + def test_created_instances(self): toggle1 = toggles.SettingToggle("NAME1", default=False, module_name="module1") toggle2 = toggles.SettingToggle("NAME2", default=False, module_name="module2") @@ -71,3 +78,22 @@ def test_deleted_instances_are_not_listed(self): toggles.SettingToggle("NAME1", default=False, module_name="module1") instances = toggles.SettingToggle.get_instances() self.assertEqual([], instances) + + +class TestWaffleSwitch(TestCase): + """ + Tests the WaffleSwitch. + """ + + NAMESPACE_NAME = "test_namespace" + WAFFLE_SWITCH_NAME = "test_switch_name" + TEST_NAMESPACE = toggles.WaffleSwitchNamespace(NAMESPACE_NAME) + WAFFLE_SWITCH = toggles.WaffleSwitch(TEST_NAMESPACE, WAFFLE_SWITCH_NAME, __name__) + + def test_namespaced_switch_name(self): + """ + Verify namespaced_switch_name returns the correct namespace switch name + """ + expected = self.NAMESPACE_NAME + "." + self.WAFFLE_SWITCH_NAME + actual = self.WAFFLE_SWITCH.namespaced_switch_name + self.assertEqual(actual, expected) diff --git a/edx_toggles/toggles/__init__.py b/edx_toggles/toggles/__init__.py index 7d98c2e6..7c9ef83a 100644 --- a/edx_toggles/toggles/__init__.py +++ b/edx_toggles/toggles/__init__.py @@ -1,4 +1,5 @@ """ Expose public feature toggle API. """ -from .internal import SettingDictToggle, SettingToggle +from .internal.setting_toggle import SettingDictToggle, SettingToggle +from .internal.waffle import WaffleFlag, WaffleFlagNamespace, WaffleSwitch, WaffleSwitchNamespace diff --git a/edx_toggles/toggles/internal.py b/edx_toggles/toggles/internal.py deleted file mode 100644 index 99aca6a3..00000000 --- a/edx_toggles/toggles/internal.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -This module includes all code related to feature toggles. Remember to import publicly available classes and functions -in __init__.py. -""" - -from abc import ABC -from weakref import WeakSet - -from django.conf import settings - - -class BaseToggle(ABC): - """ - This abstract base class exposes the basic API required by toggle classes. Toggle instances are tracked in the - ``_class_instances`` class attribute, which is exposed via the ``get_instaaces`` class method. - """ - - _class_instances = WeakSet() - - def __init__(self, name, default=False, module_name=""): - self.name = name - self.default = default - self.module_name = module_name - self._class_instances.add(self) - - def is_enabled(self): - raise NotImplementedError - - @classmethod - def get_instances(cls): - """ - Return the list of class instances sorted by name. - """ - return sorted(cls._class_instances, key=lambda instance: instance.name) - - -class SettingToggle(BaseToggle): - """ - Feature toggle based on a Django setting value. Use as follows: - - MY_FEATURE = SettingToggle("SETTING_NAME", default=False, module_name=__name__) - """ - def is_enabled(self): - return bool(getattr(settings, self.name, self.default)) - - -class SettingDictToggle(BaseToggle): - """ - Feature toggle based on the value of a key in a Django setting ``dict``. Use as follows: - - MY_FEATURE = SettingDictToggle("SETTING_NAME", "key" default=False, module_name=__name__) - """ - def __init__(self, name, key, default=False): - super().__init__(name, default=default) - self.key = key - - def is_enabled(self): - setting_dict = getattr(settings, self.name, {}) - return bool(setting_dict.get(self.key, self.default)) diff --git a/edx_toggles/toggles/internal/__init__.py b/edx_toggles/toggles/internal/__init__.py new file mode 100644 index 00000000..2ce449e3 --- /dev/null +++ b/edx_toggles/toggles/internal/__init__.py @@ -0,0 +1,4 @@ +""" +This module includes all code related to feature toggles. Remember to import publicly available classes and functions +in toggles/__init__.py. +""" diff --git a/edx_toggles/toggles/internal/base.py b/edx_toggles/toggles/internal/base.py new file mode 100644 index 00000000..82f7cee5 --- /dev/null +++ b/edx_toggles/toggles/internal/base.py @@ -0,0 +1,68 @@ +""" +Feature toggle base classes +""" + +from abc import ABC + + +class BaseToggle(ABC): + """ + This abstract base class exposes the basic API required by toggle classes. Toggle instances are tracked in the + ``_class_instances`` class method, which is exposed via the ``get_instances`` class method. + """ + + # Each child class should implement its own cache of class instances, for instance via WeakSet objects. + _class_instances = None + + def __init__(self, name, default=False, module_name=""): + self.name = name + self.default = default + self.module_name = module_name + self._class_instances.add(self) + + def is_enabled(self): + raise NotImplementedError + + @classmethod + def get_instances(cls): + """ + Return the list of class instances sorted by name. + """ + return sorted(cls._class_instances, key=lambda instance: instance.name) + + +class BaseNamespace(ABC): + """ + A base class for a request cached namespace for waffle flags/switches. + + An instance of this class represents a single namespace + (e.g. "course_experience"), and can be used to work with a set of + flags or switches that will all share this namespace. + """ + + def __init__(self, name, log_prefix=None): + """ + Initializes the waffle namespace instance. + + Arguments: + name (String): Namespace string appended to start of all waffle + flags and switches (e.g. "grades") + log_prefix (String): Optional string to be appended to log messages + (e.g. "Grades: "). Defaults to ''. + + """ + assert name, "The name is required." + self.name = name + self.log_prefix = log_prefix if log_prefix else "" + + def _namespaced_name(self, setting_name): + """ + Returns the namespaced name of the waffle switch/flag. + + For example, the namespaced name of a waffle switch/flag would be: + my_namespace.my_setting_name + + Arguments: + setting_name (String): The name of the flag or switch. + """ + return "{}.{}".format(self.name, setting_name) diff --git a/edx_toggles/toggles/internal/setting_toggle.py b/edx_toggles/toggles/internal/setting_toggle.py new file mode 100644 index 00000000..6ef8a4bf --- /dev/null +++ b/edx_toggles/toggles/internal/setting_toggle.py @@ -0,0 +1,39 @@ +""" +Setting-derived feature toggles +""" +from weakref import WeakSet + +from django.conf import settings + +from .base import BaseToggle + + +class SettingToggle(BaseToggle): + """ + Feature toggle based on a Django setting value. Use as follows: + + MY_FEATURE = SettingToggle("SETTING_NAME", default=False, module_name=__name__) + """ + + _class_instances = WeakSet() + + def is_enabled(self): + return bool(getattr(settings, self.name, self.default)) + + +class SettingDictToggle(BaseToggle): + """ + Feature toggle based on the value of a key in a Django setting ``dict``. Use as follows: + + MY_FEATURE = SettingDictToggle("SETTING_NAME", "key" default=False, module_name=__name__) + """ + + _class_instances = WeakSet() + + def __init__(self, name, key, default=False, module_name=""): + super().__init__(name, default=default, module_name=module_name) + self.key = key + + def is_enabled(self): + setting_dict = getattr(settings, self.name, {}) + return bool(setting_dict.get(self.key, self.default)) diff --git a/edx_toggles/toggles/internal/waffle.py b/edx_toggles/toggles/internal/waffle.py new file mode 100644 index 00000000..af56023a --- /dev/null +++ b/edx_toggles/toggles/internal/waffle.py @@ -0,0 +1,314 @@ +""" +Waffle classes in the context of edx-platform and other IDAs. + +Includes namespacing and caching for waffle flags. + +Usage: + +For Waffle Flags, first set up the namespace, and then create flags using the +namespace. For example:: + + WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='my_namespace') + SOME_FLAG = WaffleFlag(WAFFLE_FLAG_NAMESPACE, 'some_feature', __name__) + +You can check theis flag in code using the following:: + + SOME_FLAG.is_enabled() + +To test these WaffleFlags, see testutils.py. + +In the above examples, you will use Django Admin "waffle" section to configure +for a flag named: my_namespace.some_course_feature + +For Waffle Switches, follow the example above for WaffleFlags, but instead use the WaffleSwitchNamespace and +WaffleSwitch classes. + +For long-lived flags, you may want to change the default for devstack, sandboxes, +or new Open edX releases. For help with this, see: +openedx/core/djangoapps/waffle_utils/docs/decisions/0001-refactor-waffle-flag-default.rst + +Also see ``WAFFLE_FLAG_CUSTOM_ATTRIBUTES`` and docstring for _set_waffle_flag_attribute +for temporarily instrumenting/monitoring waffle flag usage. +""" +import logging +from weakref import WeakSet + +import crum +from django.conf import settings +from edx_django_utils.cache import RequestCache +from edx_django_utils.monitoring import set_custom_attribute +from waffle import flag_is_active, switch_is_active + +from .base import BaseNamespace, BaseToggle + +log = logging.getLogger(__name__) + + +class WaffleSwitchNamespace(BaseNamespace): + """ + Provides a single namespace for a set of waffle switches. + + All namespaced switch values are stored in a single request cache containing + all switches for all namespaces. + """ + + def is_enabled(self, switch_name): + """ + Returns and caches whether the given waffle switch is enabled. + """ + namespaced_switch_name = self._namespaced_name(switch_name) + value = self._cached_switches.get(namespaced_switch_name) + if value is None: + value = switch_is_active(namespaced_switch_name) + self._cached_switches[namespaced_switch_name] = value + return value + + @property + def _cached_switches(self): + """ + Return a dictionary of all namespaced switches in the request cache. + """ + return _get_waffle_namespace_request_cache().setdefault("switches", {}) + + +class WaffleSwitch(BaseToggle): + """ + Represents a single waffle switch, using a cached namespace. + """ + + NAMESPACE_CLASS = WaffleSwitchNamespace + _class_instances = WeakSet() + + def __init__(self, waffle_namespace, switch_name, module_name=None): + """ + Arguments: + waffle_namespace (Namespace | String): Namespace for this switch. + switch_name (String): The name of the switch (without namespacing). + module_name (String): The name of the module where the flag is created. This should be ``__name__`` in most + cases. + """ + if isinstance(waffle_namespace, str): + waffle_namespace = self.NAMESPACE_CLASS(name=waffle_namespace) + + self.waffle_namespace = waffle_namespace + self.switch_name = switch_name + + # Note that the waffle constructor does not provide a default + name = self.waffle_namespace._namespaced_name(self.switch_name) + super().__init__(name, default=False, module_name=module_name) + + @property + def namespaced_switch_name(self): + """ + For backward compatibility, we still provide the `namespaced_switch_name`, property, even though users should + now use the `name` attribute. + """ + return self.name + + def is_enabled(self): + return self.waffle_namespace.is_enabled(self.switch_name) + + +class WaffleFlagNamespace(BaseNamespace): + """ + Provides a single namespace for a set of waffle flags. + + All namespaced flag values are stored in a single request cache containing + all flags for all namespaces. + """ + + @property + def _cached_flags(self): + """ + Returns a dictionary of all namespaced flags in the request cache. + """ + return _get_waffle_namespace_request_cache().setdefault("flags", {}) + + def is_flag_active(self, flag_name): + """ + Returns and caches whether the provided flag is active. + + If the flag value is already cached in the request, it is returned. + + Note: A waffle flag's default is False if not defined. If you think you + need the default to be True, see the module docstring for + alternatives. + + Arguments: + flag_name (String): The name of the flag to check. + """ + namespaced_flag_name = self._namespaced_name(flag_name) + value = self._get_flag_active(namespaced_flag_name) + self._monitor_value(namespaced_flag_name, value) + return value + + def _get_flag_active(self, namespaced_flag_name): + """ + Return and cache the value of the flag activation. This does not handle monitoring. + """ + # Check namespace cache + value = self._cached_flags.get(namespaced_flag_name) + if value is not None: + return value + + # Check in context of request + request = crum.get_current_request() + value = self._get_flag_active_request(namespaced_flag_name, request) + if value is not None: + return value + + # Return default value + return self._get_flag_active_no_request(namespaced_flag_name) + + def _get_flag_active_request(self, namespaced_flag_name, request): + """ + Get flag value in the context of the current request. + """ + if request: + value = flag_is_active(request, namespaced_flag_name) + self._cached_flags[namespaced_flag_name] = value + return value + return None + + def _get_flag_active_no_request(self, namespaced_flag_name): + """ + Return default value in the absence of any other, more specific flag value. This triggers warnings, as waffle + flag values are not supposed to be accessed in the absence of any request context. + + Note: this skips the cache as the value might be different in a normal request context. This case seems to + occur when a page redirects to a 404, or for celery workers. + """ + log.warning( + u"%sFlag '%s' accessed without a request", + self.log_prefix, + namespaced_flag_name, + ) + value = _is_flag_active_for_everyone(namespaced_flag_name) + set_custom_attribute("warn_flag_no_request_return_value", value) + return value + + @staticmethod + def _monitor_value(namespaced_flag_name, value): + """ + Send waffle flag value to monitoring. We keep this method such that it can be called by child classes (such as + edx-platform's waffle_utils.CourseWaffleFlag), but it should not be considered a stable API. + """ + _set_waffle_flag_attribute(namespaced_flag_name, value) + + +def _get_waffle_namespace_request_cache(): + """ + Returns a request cache shared by all Waffle namespace objects. + """ + return RequestCache("WaffleNamespace").data + + +def _is_flag_active_for_everyone(namespaced_flag_name): + """ + Returns True if the waffle flag is configured as active for Everyone, + False otherwise. + """ + # Import is placed here to avoid model import at project startup. + # pylint: disable=import-outside-toplevel + from waffle.models import Flag + + try: + waffle_flag = Flag.objects.get(name=namespaced_flag_name) + return waffle_flag.everyone is True + except Flag.DoesNotExist: + return False + + +class WaffleFlag(BaseToggle): + """ + Represents a single waffle flag, using a cached waffle namespace. + """ + + NAMESPACE_CLASS = WaffleFlagNamespace + _class_instances = WeakSet() + + def __init__(self, waffle_namespace, flag_name, module_name=None): + """ + Initializes the waffle flag instance. + + Arguments: + waffle_namespace (WaffleFlagNamespace | String): Namespace for this flag. + flag_name (String): The name of the flag (without namespacing). + module_name (String): The name of the module where the flag is created. This should be ``__name__`` in most + cases. + """ + if isinstance(waffle_namespace, str): + waffle_namespace = self.NAMESPACE_CLASS(name=waffle_namespace) + + self.waffle_namespace = waffle_namespace + self.flag_name = flag_name + + # Note that the waffle constructor does not provide a default + name = self.waffle_namespace._namespaced_name(self.flag_name) + super().__init__(name, default=False, module_name=module_name) + + @property + def namespaced_flag_name(self): + """ + Returns the fully namespaced flag name. + """ + return self.name + + def is_enabled(self): + """ + Returns whether or not the flag is enabled. + """ + return self.waffle_namespace.is_flag_active(self.flag_name) + + +def _set_waffle_flag_attribute(name, value): + """ + For any flag name in settings.WAFFLE_FLAG_CUSTOM_ATTRIBUTES, add name/value + to cached values and set custom attribute if the value changed. + + Important: Remember to configure ``WAFFLE_FLAG_CUSTOM_ATTRIBUTES`` for + LMS, Studio and Workers in order to see waffle flag usage in all + edx-platform environments. + + .. setting_name: WAFFLE_FLAG_CUSTOM_ATTRIBUTES + .. setting_default: False + .. setting_description: A set of waffle flags to track with custom attributes having + values of (True, False, or Both). The name of the custom attribute will have the prefix ``flag_`` and the suffix + will match the name of the flag. The value of the custom attribute could be False, True, or Both. + + The value Both would mean that the flag had both a True and False value at different times during the + transaction. This is most likely due to happen in WaffleFlag child classes, such as edx-platform's + waffle_utils.CourseWaffleFlag. + + An example NewRelic query to see the values of a flag in different environments, if your waffle flag was named + ``my.waffle.flag`` might look like:: + + SELECT count(*) FROM Transaction + WHERE flag_my.waffle.flag IS NOT NULL + FACET appName, flag_my.waffle.flag + + .. setting_warning: This will work if it is a list, but it might be less performant. + """ + custom_attributes = getattr(settings, "WAFFLE_FLAG_CUSTOM_ATTRIBUTES", None) or [] + if name not in custom_attributes: + return + + flag_attribute_data = _get_waffle_namespace_request_cache().setdefault( + "flag_attribute", {} + ) + is_value_changed = True + if name not in flag_attribute_data: + # New flag + flag_attribute_data[name] = str(value) + else: + # Existing flag + if flag_attribute_data[name] == str(value): + # Same value + is_value_changed = False + else: + # New value + flag_attribute_data[name] = "Both" + + if is_value_changed: + attribute_name = "flag_{}".format(name) + set_custom_attribute(attribute_name, flag_attribute_data[name]) diff --git a/edx_toggles/toggles/testutils.py b/edx_toggles/toggles/testutils.py new file mode 100644 index 00000000..d7da3c3c --- /dev/null +++ b/edx_toggles/toggles/testutils.py @@ -0,0 +1,57 @@ +""" +Toggle test utilities. +""" +from waffle.testutils import override_flag + + +class override_waffle_flag(override_flag): + """ + override_waffle_flag is a contextmanager for easier testing of flags. + + It accepts two parameters, the flag itself and its intended state. Example + usage:: + + with override_waffle_flag(SOME_COURSE_FLAG, active=True): + ... + + If the flag already exists, its value will be changed inside the context + block, then restored to the original value. If the flag does not exist + before entering the context, it is created, then removed at the end of the + block. + + It can also act as a decorator:: + + @override_waffle_flag(SOME_COURSE_FLAG, active=True) + def test_happy_mode_enabled(): + ... + """ + + def __init__(self, flag, active): + """ + + Args: + flag (WaffleFlag): The namespaced cached waffle flag. + active (Boolean): The value to which the flag will be set. + """ + self.flag = flag + waffle_namespace = flag.waffle_namespace + name = waffle_namespace._namespaced_name(flag.flag_name) + self._cached_value = None + super(override_waffle_flag, self).__init__(name, active) + + def __enter__(self): + super(override_waffle_flag, self).__enter__() + + # Store values that have been cached on the flag + self._cached_value = self.flag.waffle_namespace._cached_flags.get(self.name) + self.flag.waffle_namespace._cached_flags[self.name] = self.active + + def __exit__(self, exc_type, exc_val, exc_tb): + super(override_waffle_flag, self).__exit__(exc_type, exc_val, exc_tb) + + # Restore the cached values + waffle_namespace = self.flag.waffle_namespace + waffle_namespace._cached_flags.pop(self.name, None) + + if self._cached_value is not None: + waffle_namespace._cached_flags[self.name] = self._cached_value diff --git a/edx_toggles/urls.py b/edx_toggles/urls.py index 55ca36dd..978a2064 100644 --- a/edx_toggles/urls.py +++ b/edx_toggles/urls.py @@ -3,10 +3,4 @@ URLs for edx_toggles. """ - -from django.conf.urls import url -from django.views.generic import TemplateView - -urlpatterns = [ - url('', TemplateView.as_view(template_name="edx_toggles/base.html")), -] +urlpatterns = [] diff --git a/requirements/base.in b/requirements/base.in index c2a53465..93ccf366 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -4,4 +4,6 @@ code-annotations # Parse annotations in codebases Django>=2.2 # Web application framework -django-waffle==0.12.0 # pinned to match the version in edx/edx-platform +django-crum # Middleware that stores the current request and user in thread local storage +django-waffle # Django feature flipper +edx-django-utils # Utilities for cache and monitoring diff --git a/requirements/base.txt b/requirements/base.txt index 04fabacd..9093753a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,15 +6,19 @@ # click==7.1.2 # via code-annotations code-annotations==0.9.0 # via -r requirements/base.in -django-waffle==0.12.0 # via -r requirements/base.in -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.in, code-annotations +django-crum==0.7.7 # via -r requirements/base.in +django-waffle==2.0.0 # via -r requirements/base.in, edx-django-utils +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.in, code-annotations, django-crum, edx-django-utils +edx-django-utils==3.8.0 # via -r requirements/base.in jinja2==2.11.2 # via code-annotations markupsafe==1.1.1 # via jinja2 +newrelic==5.20.1.150 # via edx-django-utils pbr==5.5.0 # via stevedore +psutil==5.7.2 # via edx-django-utils python-slugify==4.0.1 # via code-annotations pytz==2020.1 # via django pyyaml==5.3.1 # via code-annotations six==1.15.0 # via stevedore sqlparse==0.4.1 # via django -stevedore==1.32.0 # via -c requirements/constraints.txt, code-annotations +stevedore==1.32.0 # via -c requirements/constraints.txt, code-annotations, edx-django-utils text-unidecode==1.3 # via python-slugify diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 46ca2c18..c1eb637e 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -12,7 +12,7 @@ Django<3 # zipp > 1.2.0 does not work with Python 3.5 -zipp==1.2.0 +zipp<=1.2.0 # stevedore > 1.32.0 does not work with Python 3.5 stevedore==1.32.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 17cbc90a..42131e02 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -17,8 +17,10 @@ codecov==2.1.10 # via -r requirements/travis.txt coverage==5.3 # via -r requirements/quality.txt, -r requirements/travis.txt, codecov, pytest-cov diff-cover==4.0.1 # via -r requirements/dev.in distlib==0.3.1 # via -r requirements/travis.txt, virtualenv -django-waffle==0.12.0 # via -r requirements/quality.txt -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/quality.txt, code-annotations, edx-i18n-tools +django-crum==0.7.7 # via -r requirements/quality.txt +django-waffle==2.0.0 # via -r requirements/quality.txt, edx-django-utils +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/quality.txt, code-annotations, django-crum, edx-django-utils, edx-i18n-tools +edx-django-utils==3.8.0 # via -r requirements/quality.txt edx-i18n-tools==0.5.3 # via -r requirements/dev.in edx-lint==1.5.2 # via -r requirements/quality.txt filelock==3.0.12 # via -r requirements/travis.txt, tox, virtualenv @@ -34,6 +36,7 @@ lazy-object-proxy==1.4.3 # via -r requirements/quality.txt, astroid markupsafe==1.1.1 # via -r requirements/quality.txt, jinja2 mccabe==0.6.1 # via -r requirements/quality.txt, pylint mysql-connector-python-rf==2.2.2 # via -r requirements/quality.txt +newrelic==5.20.1.150 # via -r requirements/quality.txt, edx-django-utils oauthlib==3.1.0 # via -r requirements/quality.txt, atlassian-python-api, requests-oauthlib packaging==20.4 # via -r requirements/quality.txt, -r requirements/travis.txt, pytest, tox path.py==12.5.0 # via edx-i18n-tools @@ -43,6 +46,7 @@ pbr==5.5.0 # via -r requirements/quality.txt, stevedore pip-tools==5.3.1 # via -r requirements/pip-tools.txt pluggy==0.13.1 # via -r requirements/quality.txt, -r requirements/travis.txt, diff-cover, pytest, tox polib==1.1.0 # via edx-i18n-tools +psutil==5.7.2 # via -r requirements/quality.txt, edx-django-utils py==1.9.0 # via -r requirements/quality.txt, -r requirements/travis.txt, pytest, tox pycodestyle==2.6.0 # via -r requirements/quality.txt pydocstyle==5.1.1 # via -r requirements/quality.txt @@ -63,7 +67,7 @@ requests==2.24.0 # via -r requirements/quality.txt, -r requirements/tra six==1.15.0 # via -r requirements/pip-tools.txt, -r requirements/quality.txt, -r requirements/travis.txt, astroid, atlassian-python-api, edx-i18n-tools, edx-lint, packaging, pathlib2, pip-tools, stevedore, tox, virtualenv snowballstemmer==2.0.0 # via -r requirements/quality.txt, pydocstyle sqlparse==0.4.1 # via -r requirements/quality.txt, django -stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/quality.txt, code-annotations +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/quality.txt, code-annotations, edx-django-utils text-unidecode==1.3 # via -r requirements/quality.txt, python-slugify toml==0.10.1 # via -r requirements/quality.txt, -r requirements/travis.txt, pytest, tox tox-battery==0.6.1 # via -r requirements/travis.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 54f49c7d..d419b4bb 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -14,10 +14,12 @@ chardet==3.0.4 # via -r requirements/test.txt, doc8, requests click==7.1.2 # via -r requirements/test.txt, code-annotations code-annotations==0.9.0 # via -r requirements/test.txt coverage==5.3 # via -r requirements/test.txt, pytest-cov -django-waffle==0.12.0 # via -r requirements/test.txt -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations +django-crum==0.7.7 # via -r requirements/test.txt +django-waffle==2.0.0 # via -r requirements/test.txt, edx-django-utils +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations, django-crum, edx-django-utils doc8==0.8.1 # via -r requirements/doc.in docutils==0.16 # via doc8, readme-renderer, restructuredtext-lint, sphinx +edx-django-utils==3.8.0 # via -r requirements/test.txt edx-sphinx-theme==1.5.0 # via -r requirements/doc.in idna==2.10 # via -r requirements/test.txt, requests imagesize==1.2.0 # via sphinx @@ -26,11 +28,13 @@ iniconfig==1.1.1 # via -r requirements/test.txt, pytest jinja2==2.11.2 # via -r requirements/test.txt, code-annotations, sphinx markupsafe==1.1.1 # via -r requirements/test.txt, jinja2 mysql-connector-python-rf==2.2.2 # via -r requirements/test.txt +newrelic==5.20.1.150 # via -r requirements/test.txt, edx-django-utils oauthlib==3.1.0 # via -r requirements/test.txt, atlassian-python-api, requests-oauthlib packaging==20.4 # via -r requirements/test.txt, bleach, pytest, sphinx pathlib2==2.3.5 # via -r requirements/test.txt, pytest pbr==5.5.0 # via -r requirements/test.txt, stevedore pluggy==0.13.1 # via -r requirements/test.txt, pytest +psutil==5.7.2 # via -r requirements/test.txt, edx-django-utils py==1.9.0 # via -r requirements/test.txt, pytest pygments==2.7.1 # via doc8, readme-renderer, sphinx pyparsing==2.4.7 # via -r requirements/test.txt, packaging @@ -54,7 +58,7 @@ sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx sqlparse==0.4.1 # via -r requirements/test.txt, django -stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations, doc8 +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations, doc8, edx-django-utils text-unidecode==1.3 # via -r requirements/test.txt, python-slugify toml==0.10.1 # via -r requirements/test.txt, pytest urllib3==1.25.10 # via -r requirements/test.txt, requests diff --git a/requirements/quality.txt b/requirements/quality.txt index af27895f..893c9a32 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -13,8 +13,10 @@ click-log==0.3.2 # via edx-lint click==7.1.2 # via -r requirements/test.txt, click-log, code-annotations, edx-lint code-annotations==0.9.0 # via -r requirements/test.txt coverage==5.3 # via -r requirements/test.txt, pytest-cov -django-waffle==0.12.0 # via -r requirements/test.txt -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations +django-crum==0.7.7 # via -r requirements/test.txt +django-waffle==2.0.0 # via -r requirements/test.txt, edx-django-utils +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations, django-crum, edx-django-utils +edx-django-utils==3.8.0 # via -r requirements/test.txt edx-lint==1.5.2 # via -r requirements/quality.in idna==2.10 # via -r requirements/test.txt, requests importlib-metadata==2.0.0 # via -r requirements/test.txt, pluggy, pytest @@ -25,11 +27,13 @@ lazy-object-proxy==1.4.3 # via astroid markupsafe==1.1.1 # via -r requirements/test.txt, jinja2 mccabe==0.6.1 # via pylint mysql-connector-python-rf==2.2.2 # via -r requirements/test.txt +newrelic==5.20.1.150 # via -r requirements/test.txt, edx-django-utils oauthlib==3.1.0 # via -r requirements/test.txt, atlassian-python-api, requests-oauthlib packaging==20.4 # via -r requirements/test.txt, pytest pathlib2==2.3.5 # via -r requirements/test.txt, pytest pbr==5.5.0 # via -r requirements/test.txt, stevedore pluggy==0.13.1 # via -r requirements/test.txt, pytest +psutil==5.7.2 # via -r requirements/test.txt, edx-django-utils py==1.9.0 # via -r requirements/test.txt, pytest pycodestyle==2.6.0 # via -r requirements/quality.in pydocstyle==5.1.1 # via -r requirements/quality.in @@ -49,7 +53,7 @@ requests==2.24.0 # via -r requirements/test.txt, atlassian-python-api, six==1.15.0 # via -r requirements/test.txt, astroid, atlassian-python-api, edx-lint, packaging, pathlib2, stevedore snowballstemmer==2.0.0 # via pydocstyle sqlparse==0.4.1 # via -r requirements/test.txt, django -stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/test.txt, code-annotations, edx-django-utils text-unidecode==1.3 # via -r requirements/test.txt, python-slugify toml==0.10.1 # via -r requirements/test.txt, pytest typed-ast==1.4.1 # via astroid diff --git a/requirements/scripts.txt b/requirements/scripts.txt index 6219409c..416c7ce2 100644 --- a/requirements/scripts.txt +++ b/requirements/scripts.txt @@ -9,14 +9,18 @@ certifi==2020.6.20 # via requests chardet==3.0.4 # via requests click==7.1.2 # via -r requirements/base.txt, code-annotations code-annotations==0.9.0 # via -r requirements/base.txt, -r requirements/scripts.in -django-waffle==0.12.0 # via -r requirements/base.txt -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.txt, code-annotations +django-crum==0.7.7 # via -r requirements/base.txt +django-waffle==2.0.0 # via -r requirements/base.txt, edx-django-utils +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.txt, code-annotations, django-crum, edx-django-utils +edx-django-utils==3.8.0 # via -r requirements/base.txt idna==2.10 # via requests jinja2==2.11.2 # via -r requirements/base.txt, code-annotations markupsafe==1.1.1 # via -r requirements/base.txt, jinja2 mysql-connector-python-rf==2.2.2 # via -r requirements/scripts.in +newrelic==5.20.1.150 # via -r requirements/base.txt, edx-django-utils oauthlib==3.1.0 # via atlassian-python-api, requests-oauthlib pbr==5.5.0 # via -r requirements/base.txt, stevedore +psutil==5.7.2 # via -r requirements/base.txt, edx-django-utils python-slugify==4.0.1 # via -r requirements/base.txt, code-annotations pytz==2020.1 # via -r requirements/base.txt, django pyyaml==5.3.1 # via -r requirements/base.txt, code-annotations @@ -24,6 +28,6 @@ requests-oauthlib==1.3.0 # via atlassian-python-api requests==2.24.0 # via atlassian-python-api, requests-oauthlib six==1.15.0 # via -r requirements/base.txt, atlassian-python-api, stevedore sqlparse==0.4.1 # via -r requirements/base.txt, django -stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, code-annotations +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, code-annotations, edx-django-utils text-unidecode==1.3 # via -r requirements/base.txt, python-slugify urllib3==1.25.10 # via requests diff --git a/requirements/test.txt b/requirements/test.txt index 4f0b9bd3..f829885a 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -11,18 +11,22 @@ chardet==3.0.4 # via -r requirements/scripts.txt, requests click==7.1.2 # via -r requirements/base.txt, -r requirements/scripts.txt, code-annotations code-annotations==0.9.0 # via -r requirements/base.txt, -r requirements/scripts.txt, -r requirements/test.in coverage==5.3 # via pytest-cov -django-waffle==0.12.0 # via -r requirements/base.txt, -r requirements/scripts.txt +django-crum==0.7.7 # via -r requirements/base.txt, -r requirements/scripts.txt +django-waffle==2.0.0 # via -r requirements/base.txt, -r requirements/scripts.txt, edx-django-utils +edx-django-utils==3.8.0 # via -r requirements/base.txt, -r requirements/scripts.txt idna==2.10 # via -r requirements/scripts.txt, requests importlib-metadata==2.0.0 # via pluggy, pytest iniconfig==1.1.1 # via pytest jinja2==2.11.2 # via -r requirements/base.txt, -r requirements/scripts.txt, code-annotations markupsafe==1.1.1 # via -r requirements/base.txt, -r requirements/scripts.txt, jinja2 mysql-connector-python-rf==2.2.2 # via -r requirements/scripts.txt +newrelic==5.20.1.150 # via -r requirements/base.txt, -r requirements/scripts.txt, edx-django-utils oauthlib==3.1.0 # via -r requirements/scripts.txt, atlassian-python-api, requests-oauthlib packaging==20.4 # via pytest pathlib2==2.3.5 # via pytest pbr==5.5.0 # via -r requirements/base.txt, -r requirements/scripts.txt, stevedore pluggy==0.13.1 # via pytest +psutil==5.7.2 # via -r requirements/base.txt, -r requirements/scripts.txt, edx-django-utils py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging pytest-cov==2.10.1 # via -r requirements/test.in @@ -35,7 +39,7 @@ requests-oauthlib==1.3.0 # via -r requirements/scripts.txt, atlassian-python-ap requests==2.24.0 # via -r requirements/scripts.txt, atlassian-python-api, requests-oauthlib six==1.15.0 # via -r requirements/base.txt, -r requirements/scripts.txt, atlassian-python-api, packaging, pathlib2, stevedore sqlparse==0.4.1 # via -r requirements/base.txt, -r requirements/scripts.txt, django -stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, -r requirements/scripts.txt, code-annotations +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, -r requirements/scripts.txt, code-annotations, edx-django-utils text-unidecode==1.3 # via -r requirements/base.txt, -r requirements/scripts.txt, python-slugify toml==0.10.1 # via pytest urllib3==1.25.10 # via -r requirements/scripts.txt, requests diff --git a/test_settings.py b/test_settings.py index 505d2a33..2b6c646a 100644 --- a/test_settings.py +++ b/test_settings.py @@ -31,6 +31,7 @@ def root(*args): INSTALLED_APPS = ( 'django.contrib.auth', 'django.contrib.contenttypes', + 'waffle', 'edx_toggles', )