Plain is headed towards 1.0! Subscribe for development updates →

plain.flags

Local feature flags via database models.

Custom flags are written as subclasses of Flag. You define the flag's "key" and initial value, and the results will be stored in the database for future reference.

# app/flags.py
from plain.flags import Flag


class FooEnabled(Flag):
    def __init__(self, user):
        self.user = user

    def get_key(self):
        return self.user

    def get_value(self):
        # Initially all users will have this feature disabled
        # and we'll enable them manually in the admin
        return False

Use flags in HTML templates:

{% if flags.FooEnabled(request.user) %}
    <p>Foo is enabled for you!</p>
{% else %}
    <p>Foo is disabled for you.</p>
{% endif %}

Or in Python:

import flags


print(flags.FooEnabled(user).value)

Installation

INSTALLED_PACKAGES = [
    ...
    "plain.flags",
]

Create a flags.py at the top of your app (or point settings.FLAGS_MODULE to a different location).

Advanced usage

Ultimately you can do whatever you want inside of get_key and get_value.

class OrganizationFeature(Flag):
    url_param_name = ""

    def __init__(self, request=None, organization=None):
        # Both of these are optional, but will usually both be given
        self.request = request
        self.organization = organization

    def get_key(self):
        if (
            self.url_param_name
            and self.request
            and self.url_param_name in self.request.GET
        ):
            return None

        if not self.organization:
            # Don't save the flag result for PRs without an organization
            return None

        return self.organization

    def get_value(self):
        if self.url_param_name and self.request:
            if self.request.GET.get(self.url_param_name) == "1":
                return True

            if self.request.GET.get(self.url_param_name) == "0":
                return False

        if not self.organization:
            return False

        # All organizations will start with False,
        # and I'll override in the DB for the ones that should be True
        return False


class AIEnabled(OrganizationFeature):
    pass
  1import logging
  2from typing import Any
  3
  4from plain.runtime import settings
  5from plain.utils import timezone
  6from plain.utils.functional import cached_property
  7
  8from . import exceptions
  9from .utils import coerce_key
 10
 11logger = logging.getLogger(__name__)
 12
 13
 14class Flag:
 15    def get_key(self) -> Any:
 16        """
 17        Determine a unique key for this instance of the flag.
 18        This should be a quick operation, as it will be called on every use of the flag.
 19
 20        For convenience, you can return an instance of a Plain Model
 21        and it will be converted to a string automatically.
 22
 23        Return a falsy value if you don't want to store the flag result.
 24        """
 25        raise NotImplementedError
 26
 27    def get_value(self) -> Any:
 28        """
 29        Compute the resulting value of the flag.
 30
 31        The value needs to be JSON serializable.
 32
 33        If get_key() returns a value, this will only be called once per key
 34        and then subsequent calls will return the saved value from the DB.
 35        """
 36        raise NotImplementedError
 37
 38    def get_db_name(self) -> str:
 39        """
 40        Should basically always be the name of the class.
 41        But this is overridable in case of renaming/refactoring/importing.
 42        """
 43        return self.__class__.__name__
 44
 45    def retrieve_or_compute_value(self) -> Any:
 46        """
 47        Retrieve the value from the DB if it exists,
 48        otherwise compute the value and save it to the DB.
 49        """
 50        from .models import Flag, FlagResult  # So Plain app is ready...
 51
 52        # Create an associated DB Flag that we can use to enable/disable
 53        # and tie the results to
 54        flag_obj, _ = Flag.objects.update_or_create(
 55            name=self.get_db_name(),
 56            defaults={"used_at": timezone.now()},
 57        )
 58        if not flag_obj.enabled:
 59            msg = f"The {flag_obj} flag has been disabled and should either not be called, or be re-enabled."
 60            if settings.DEBUG:
 61                raise exceptions.FlagDisabled(msg)
 62            else:
 63                logger.exception(msg)
 64                # Might not be the type of return value expected! Better than totally crashing now though.
 65                return None
 66
 67        key = self.get_key()
 68        if not key:
 69            # No key, so we always recompute the value and return it
 70            return self.get_value()
 71
 72        key = coerce_key(key)
 73
 74        try:
 75            flag_result = FlagResult.objects.get(flag=flag_obj, key=key)
 76            return flag_result.value
 77        except FlagResult.DoesNotExist:
 78            value = self.get_value()
 79            flag_result = FlagResult.objects.create(flag=flag_obj, key=key, value=value)
 80            return flag_result.value
 81
 82    @cached_property
 83    def value(self) -> Any:
 84        """
 85        Cached version of retrieve_or_compute_value()
 86        """
 87        return self.retrieve_or_compute_value()
 88
 89    def __bool__(self) -> bool:
 90        """
 91        Allow for use in boolean expressions.
 92        """
 93        return bool(self.value)
 94
 95    def __contains__(self, item) -> bool:
 96        """
 97        Allow for use in `in` expressions.
 98        """
 99        return item in self.value
100
101    def __eq__(self, other) -> bool:
102        """
103        Allow for use in `==` expressions.
104        """
105        return self.value == other