plain.flags 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2023, Dropseed, LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.1
2
+ Name: plain.flags
3
+ Version: 0.0.0
4
+ Summary:
5
+ Author: Dave Gaeddert
6
+ Author-email: dave.gaeddert@dropseed.dev
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
@@ -0,0 +1,103 @@
1
+ # plain-flags
2
+
3
+ Local feature flags via database models.
4
+
5
+ Custom flags are written as subclasses of [`Flag`](./flags.py).
6
+ You define the flag's "key" and initial value,
7
+ and the results will be stored in the database for future reference.
8
+
9
+ ```python
10
+ # app/flags.py
11
+ from plain.flags import Flag
12
+
13
+
14
+ class FooEnabled(Flag):
15
+ def __init__(self, user):
16
+ self.user = user
17
+
18
+ def get_key(self):
19
+ return self.user
20
+
21
+ def get_value(self):
22
+ # Initially all users will have this feature disabled
23
+ # and we'll enable them manually in the admin
24
+ return False
25
+ ```
26
+
27
+ Use flags in HTML templates:
28
+
29
+ ```html
30
+ {% if flags.FooEnabled(request.user) %}
31
+ <p>Foo is enabled for you!</p>
32
+ {% else %}
33
+ <p>Foo is disabled for you.</p>
34
+ {% endif %}
35
+ ```
36
+
37
+ Or in Python:
38
+
39
+ ```python
40
+ import flags
41
+
42
+
43
+ print(flags.FooEnabled(user).value)
44
+ ```
45
+
46
+ ## Installation
47
+
48
+ ```python
49
+ INSTALLED_PACKAGES = [
50
+ ...
51
+ "plain.flags",
52
+ ]
53
+ ```
54
+
55
+ Create a `flags.py` at the top of your `app` (or point `settings.FLAGS_MODULE` to a different location).
56
+
57
+ ## Advanced usage
58
+
59
+ Ultimately you can do whatever you want inside of `get_key` and `get_value`.
60
+
61
+ ```python
62
+ class OrganizationFeature(Flag):
63
+ url_param_name = ""
64
+
65
+ def __init__(self, request=None, organization=None):
66
+ # Both of these are optional, but will usually both be given
67
+ self.request = request
68
+ self.organization = organization
69
+
70
+ def get_key(self):
71
+ if (
72
+ self.url_param_name
73
+ and self.request
74
+ and self.url_param_name in self.request.GET
75
+ ):
76
+ return None
77
+
78
+ if not self.organization:
79
+ # Don't save the flag result for PRs without an organization
80
+ return None
81
+
82
+ return self.organization
83
+
84
+ def get_value(self):
85
+ if self.url_param_name and self.request:
86
+ if self.request.GET.get(self.url_param_name) == "1":
87
+ return True
88
+
89
+ if self.request.GET.get(self.url_param_name) == "0":
90
+ return False
91
+
92
+ if not self.organization:
93
+ return False
94
+
95
+ # All organizations will start with False,
96
+ # and I'll override in the DB for the ones that should be True
97
+ return False
98
+
99
+
100
+ class AIEnabled(OrganizationFeature):
101
+ pass
102
+
103
+ ```
@@ -0,0 +1,3 @@
1
+ from .flags import Flag
2
+
3
+ __all__ = ["Flag"]
@@ -0,0 +1,75 @@
1
+ from functools import cached_property
2
+
3
+ from plain.models.forms import ModelForm
4
+ from plain.staff.admin.cards import Card
5
+ from plain.staff.admin.views import (
6
+ AdminModelDetailView,
7
+ AdminModelListView,
8
+ AdminModelUpdateView,
9
+ AdminModelViewset,
10
+ register_viewset,
11
+ )
12
+
13
+ from .models import Flag, FlagResult
14
+
15
+
16
+ class UnusedFlagsCard(Card):
17
+ title = "Unused Flags"
18
+
19
+ @cached_property
20
+ def flag_errors(self):
21
+ return Flag.check(databases=["default"])
22
+
23
+ def get_number(self):
24
+ return len(self.flag_errors)
25
+
26
+ def get_text(self):
27
+ return "\n".join(str(e.msg) for e in self.flag_errors)
28
+
29
+
30
+ @register_viewset
31
+ class FlagAdmin(AdminModelViewset):
32
+ class ListView(AdminModelListView):
33
+ model = Flag
34
+ fields = ["name", "enabled", "created_at__date", "used_at__date", "uuid"]
35
+ search_fields = ["name", "description"]
36
+ cards = [UnusedFlagsCard]
37
+ nav_section = "Feature flags"
38
+
39
+ class DetailView(AdminModelDetailView):
40
+ model = Flag
41
+
42
+
43
+ class FlagResultForm(ModelForm):
44
+ class Meta:
45
+ model = FlagResult
46
+ fields = ["key", "value"]
47
+
48
+
49
+ @register_viewset
50
+ class FlagResultAdmin(AdminModelViewset):
51
+ class ListView(AdminModelListView):
52
+ model = FlagResult
53
+ title = "Flag results"
54
+ fields = [
55
+ "flag",
56
+ "key",
57
+ "value",
58
+ "created_at__date",
59
+ "updated_at__date",
60
+ "uuid",
61
+ ]
62
+ search_fields = ["flag__name", "key"]
63
+ nav_section = "Feature flags"
64
+
65
+ def get_initial_queryset(self):
66
+ return self.model.objects.all().select_related("flag")
67
+
68
+ class DetailView(AdminModelDetailView):
69
+ model = FlagResult
70
+ title = "Flag result"
71
+
72
+ class UpdateView(AdminModelUpdateView):
73
+ model = FlagResult
74
+ title = "Update flag result"
75
+ form_class = FlagResultForm
@@ -0,0 +1,28 @@
1
+ from plain.runtime import settings
2
+
3
+ from . import exceptions
4
+ from .flags import Flag
5
+
6
+
7
+ def get_flags_module():
8
+ flags_module = settings.FLAGS_MODULE
9
+
10
+ try:
11
+ return __import__(flags_module)
12
+ except ImportError as e:
13
+ raise exceptions.FlagImportError(
14
+ f"Could not import {flags_module} module"
15
+ ) from e
16
+
17
+
18
+ def get_flag_class(flag_name: str) -> Flag:
19
+ flags_module = get_flags_module()
20
+
21
+ try:
22
+ flag_class = getattr(flags_module, flag_name)
23
+ except AttributeError as e:
24
+ raise exceptions.FlagImportError(
25
+ f"Could not find {flag_name} in {flags_module} module"
26
+ ) from e
27
+
28
+ return flag_class
@@ -0,0 +1,6 @@
1
+ from plain.packages import PackageConfig
2
+
3
+
4
+ class ForgeflagsConfig(PackageConfig):
5
+ name = "plain.flags"
6
+ label = "plainflags" # Primarily for migrations
@@ -0,0 +1 @@
1
+ FLAGS_MODULE: str = "flags"
@@ -0,0 +1,10 @@
1
+ class FlagError(Exception):
2
+ pass
3
+
4
+
5
+ class FlagDisabled(FlagError):
6
+ pass
7
+
8
+
9
+ class FlagImportError(FlagError):
10
+ pass
@@ -0,0 +1,105 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from plain.runtime import settings
5
+ from plain.utils import timezone
6
+ from plain.utils.functional import cached_property
7
+
8
+ from . import exceptions
9
+ from .utils import coerce_key
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class 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
@@ -0,0 +1,5 @@
1
+ from .bridge import get_flags_module
2
+
3
+ globals = {
4
+ "flags": get_flags_module(),
5
+ }
@@ -0,0 +1,78 @@
1
+ # Generated by Plain 4.1.7 on 2023-03-21 19:54
2
+
3
+ import uuid
4
+
5
+ import plain.flags.models
6
+ import plain.models.deletion
7
+ from plain import models
8
+ from plain.models import migrations
9
+
10
+
11
+ class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = []
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name="Flag",
19
+ fields=[
20
+ (
21
+ "id",
22
+ models.BigAutoField(
23
+ auto_created=True,
24
+ primary_key=True,
25
+ serialize=False,
26
+ ),
27
+ ),
28
+ (
29
+ "uuid",
30
+ models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
31
+ ),
32
+ ("created_at", models.DateTimeField(auto_now_add=True)),
33
+ ("updated_at", models.DateTimeField(auto_now=True)),
34
+ (
35
+ "name",
36
+ models.CharField(
37
+ max_length=255,
38
+ unique=True,
39
+ validators=[plain.flags.models.validate_flag_name],
40
+ ),
41
+ ),
42
+ ("description", models.TextField(blank=True)),
43
+ ("enabled", models.BooleanField(default=True)),
44
+ ("used_at", models.DateTimeField(blank=True, null=True)),
45
+ ],
46
+ ),
47
+ migrations.CreateModel(
48
+ name="FlagResult",
49
+ fields=[
50
+ (
51
+ "id",
52
+ models.BigAutoField(
53
+ auto_created=True,
54
+ primary_key=True,
55
+ serialize=False,
56
+ ),
57
+ ),
58
+ (
59
+ "uuid",
60
+ models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
61
+ ),
62
+ ("created_at", models.DateTimeField(auto_now_add=True)),
63
+ ("updated_at", models.DateTimeField(auto_now=True)),
64
+ ("key", models.CharField(max_length=255)),
65
+ ("value", models.JSONField()),
66
+ (
67
+ "flag",
68
+ models.ForeignKey(
69
+ on_delete=plain.models.deletion.CASCADE,
70
+ to="plainflags.flag",
71
+ ),
72
+ ),
73
+ ],
74
+ options={
75
+ "unique_together": {("flag", "key")},
76
+ },
77
+ ),
78
+ ]
File without changes
@@ -0,0 +1,93 @@
1
+ import re
2
+ import uuid
3
+
4
+ from plain.exceptions import ValidationError
5
+ from plain.models import ProgrammingError, models
6
+ from plain.preflight import Info
7
+ from plain.runtime import settings
8
+
9
+ from .bridge import get_flag_class
10
+ from .exceptions import FlagImportError
11
+
12
+
13
+ def validate_flag_name(value):
14
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", value):
15
+ raise ValidationError(f"{value} is not a valid Python identifier name")
16
+
17
+
18
+ class FlagResult(models.Model):
19
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
20
+ created_at = models.DateTimeField(auto_now_add=True)
21
+ updated_at = models.DateTimeField(auto_now=True)
22
+ flag = models.ForeignKey("Flag", on_delete=models.CASCADE)
23
+ key = models.CharField(max_length=255)
24
+ value = models.JSONField()
25
+
26
+ class Meta:
27
+ unique_together = ("flag", "key")
28
+
29
+ def __str__(self):
30
+ return self.key
31
+
32
+
33
+ class Flag(models.Model):
34
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
35
+ created_at = models.DateTimeField(auto_now_add=True)
36
+ updated_at = models.DateTimeField(auto_now=True)
37
+ name = models.CharField(
38
+ max_length=255, unique=True, validators=[validate_flag_name]
39
+ )
40
+
41
+ # Optional description that can be filled in after the flag is used/created
42
+ description = models.TextField(blank=True)
43
+
44
+ # To manually disable a flag before completing deleting
45
+ # (good to disable first to make sure the code doesn't use the flag anymore)
46
+ enabled = models.BooleanField(default=True)
47
+
48
+ # To provide an easier way to see if a flag is still being used
49
+ used_at = models.DateTimeField(blank=True, null=True)
50
+
51
+ def __str__(self):
52
+ return self.name
53
+
54
+ @classmethod
55
+ def check(cls, **kwargs):
56
+ """
57
+ Check for flags that are in the database, but no longer defined in code.
58
+
59
+ Only returns Info errors because it is valid to leave them if you're worried about
60
+ putting the flag back, but they should probably be deleted eventually.
61
+ """
62
+ errors = super().check(**kwargs)
63
+
64
+ databases = kwargs["databases"]
65
+ if not databases:
66
+ return errors
67
+
68
+ for database in databases:
69
+ flag_names = (
70
+ cls.objects.using(database).all().values_list("name", flat=True)
71
+ )
72
+
73
+ try:
74
+ flag_names = set(flag_names)
75
+ except ProgrammingError:
76
+ # The table doesn't exist yet
77
+ # (migrations probably haven't run yet),
78
+ # so we can't check it.
79
+ continue
80
+
81
+ for flag_name in flag_names:
82
+ try:
83
+ get_flag_class(flag_name)
84
+ except FlagImportError:
85
+ errors.append(
86
+ Info(
87
+ f"Flag {flag_name} is not used.",
88
+ hint=f"Remove the flag from the database or define it in the {settings.FLAGS_MODULE} module.",
89
+ id="plain.flags.I001",
90
+ )
91
+ )
92
+
93
+ return errors
File without changes
@@ -0,0 +1,8 @@
1
+ {% extends "admin/form.html" %}
2
+
3
+ {% block form_content %}
4
+ <div class="space-y-4">
5
+ <admin.InputField label="Key" field=form.key />
6
+ <admin.InputField label="Value" field=form.value />
7
+ </div>
8
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ from typing import Any
2
+
3
+ from plain import models
4
+
5
+
6
+ def coerce_key(key: Any) -> str:
7
+ """
8
+ Converts a flag key to a string for storage in the DB
9
+ (special handling of model instances)
10
+ """
11
+ if isinstance(key, str):
12
+ return key
13
+
14
+ if isinstance(key, models.Model):
15
+ return f"{key._meta.package_label}.{key._meta.model_name}:{key.pk}"
16
+
17
+ return str(key)
@@ -0,0 +1,17 @@
1
+ [tool.poetry]
2
+ name = "plain.flags"
3
+ packages = [
4
+ { include = "plain" },
5
+ ]
6
+ version = "0.0.0"
7
+ description = ""
8
+ authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
9
+ # readme = "README.md"
10
+
11
+ [tool.poetry.dependencies]
12
+ python = "^3.11"
13
+
14
+
15
+ [build-system]
16
+ requires = ["poetry-core"]
17
+ build-backend = "poetry.core.masonry.api"