Plain is headed towards 1.0! Subscribe for development updates →

  1import logging
  2from functools import cached_property
  3from typing import Any
  4
  5from opentelemetry import trace
  6from opentelemetry.semconv._incubating.attributes.feature_flag_attributes import (
  7    FEATURE_FLAG_KEY,
  8    FEATURE_FLAG_PROVIDER_NAME,
  9    FEATURE_FLAG_RESULT_REASON,
 10    FEATURE_FLAG_RESULT_VALUE,
 11    FeatureFlagResultReasonValues,
 12)
 13
 14from plain.runtime import settings
 15from plain.utils import timezone
 16
 17from . import exceptions
 18from .utils import coerce_key
 19
 20logger = logging.getLogger(__name__)
 21tracer = trace.get_tracer("plain.flags")
 22
 23
 24class Flag:
 25    def get_key(self) -> Any:
 26        """
 27        Determine a unique key for this instance of the flag.
 28        This should be a quick operation, as it will be called on every use of the flag.
 29
 30        For convenience, you can return an instance of a Plain Model
 31        and it will be converted to a string automatically.
 32
 33        Return a falsy value if you don't want to store the flag result.
 34        """
 35        raise NotImplementedError
 36
 37    def get_value(self) -> Any:
 38        """
 39        Compute the resulting value of the flag.
 40
 41        The value needs to be JSON serializable.
 42
 43        If get_key() returns a value, this will only be called once per key
 44        and then subsequent calls will return the saved value from the DB.
 45        """
 46        raise NotImplementedError
 47
 48    def get_db_name(self) -> str:
 49        """
 50        Should basically always be the name of the class.
 51        But this is overridable in case of renaming/refactoring/importing.
 52        """
 53        return self.__class__.__name__
 54
 55    def retrieve_or_compute_value(self) -> Any:
 56        """
 57        Retrieve the value from the DB if it exists,
 58        otherwise compute the value and save it to the DB.
 59        """
 60        from .models import Flag, FlagResult  # So Plain app is ready...
 61
 62        flag_name = self.get_db_name()
 63
 64        with tracer.start_as_current_span(
 65            f"flag {flag_name}",
 66            attributes={
 67                FEATURE_FLAG_PROVIDER_NAME: "plain.flags",
 68            },
 69        ) as span:
 70            # Create an associated DB Flag that we can use to enable/disable
 71            # and tie the results to
 72            flag_obj, _ = Flag.objects.update_or_create(
 73                name=flag_name,
 74                defaults={"used_at": timezone.now()},
 75            )
 76
 77            if not flag_obj.enabled:
 78                msg = f"The {flag_obj} flag has been disabled and should either not be called, or be re-enabled."
 79                span.set_attribute(
 80                    FEATURE_FLAG_RESULT_REASON,
 81                    FeatureFlagResultReasonValues.DISABLED.value,
 82                )
 83
 84                if settings.DEBUG:
 85                    raise exceptions.FlagDisabled(msg)
 86                else:
 87                    logger.exception(msg)
 88                    # Might not be the type of return value expected! Better than totally crashing now though.
 89                    return None
 90
 91            key = self.get_key()
 92            if not key:
 93                # No key, so we always recompute the value and return it
 94                value = self.get_value()
 95
 96                span.set_attribute(
 97                    FEATURE_FLAG_RESULT_REASON,
 98                    FeatureFlagResultReasonValues.DYNAMIC.value,
 99                )
100                span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(value))
101
102                return value
103
104            key = coerce_key(key)
105
106            span.set_attribute(FEATURE_FLAG_KEY, key)
107
108            try:
109                flag_result = FlagResult.objects.get(flag=flag_obj, key=key)
110
111                span.set_attribute(
112                    FEATURE_FLAG_RESULT_REASON,
113                    FeatureFlagResultReasonValues.CACHED.value,
114                )
115                span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(flag_result.value))
116
117                return flag_result.value
118            except FlagResult.DoesNotExist:
119                value = self.get_value()
120                flag_result = FlagResult.objects.create(
121                    flag=flag_obj, key=key, value=value
122                )
123
124                span.set_attribute(
125                    FEATURE_FLAG_RESULT_REASON,
126                    FeatureFlagResultReasonValues.STATIC.value,
127                )
128                span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(value))
129
130                return flag_result.value
131
132    @cached_property
133    def value(self) -> Any:
134        """
135        Cached version of retrieve_or_compute_value()
136        """
137        return self.retrieve_or_compute_value()
138
139    def __bool__(self) -> bool:
140        """
141        Allow for use in boolean expressions.
142        """
143        return bool(self.value)
144
145    def __contains__(self, item) -> bool:
146        """
147        Allow for use in `in` expressions.
148        """
149        return item in self.value
150
151    def __eq__(self, other) -> bool:
152        """
153        Allow for use in `==` expressions.
154        """
155        return self.value == other