Skip to content

Commit 9afc85d

Browse files
feat(hc): Adds Flagpole core data model (#68551)
1 parent 02ff96b commit 9afc85d

10 files changed

+1146
-0
lines changed

src/flagpole/__init__.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
Options backed feature flagging.
3+
4+
Entry backed options. Will consume option data that is structured like
5+
6+
```yaml
7+
features:
8+
organizations:fury-mode:
9+
enabled: True
10+
name: sentry organizations
11+
owner: hybrid-cloud
12+
segments:
13+
- name: sentry orgs
14+
rollout: 50
15+
conditions:
16+
- property: organization_slug
17+
name: internal organizations
18+
operator:
19+
kind: in
20+
value: ["sentry-test", "sentry"]
21+
- name: free accounts
22+
conditions:
23+
- property: subscription_is_free
24+
name: free subscriptions
25+
operator:
26+
kind: equals
27+
value: True
28+
```
29+
30+
Each feature flag has a list of segments, each of which contain a list of conditions.
31+
If all the conditions for a segment match the evaluation context, a feature is granted.
32+
A segment with multiple conditions looks like:
33+
34+
```yaml
35+
features:
36+
organizations:fury-mode:
37+
enabled: True
38+
owner: hybrid-cloud
39+
description: sentry organizations
40+
segments:
41+
- name: sentry organizations
42+
rollout: 50
43+
conditions:
44+
- name: internal orgs
45+
property: organization_slug
46+
operator:
47+
kind: in
48+
value: ["sentry-test", "sentry"]
49+
- name: allowed users
50+
property: user_email
51+
operator:
52+
kind: in
53+
value: ["mark@sentry.io", "gabe@sentry.io"]
54+
```
55+
56+
Property names are arbitrary and read from an evaluation context
57+
prepared by the application.
58+
59+
Each condition has a single operator. An operator takes a kind (`OperatorKind` enum)
60+
and a value, the type of which depends on the operator specified.
61+
"""
62+
from __future__ import annotations
63+
64+
from datetime import datetime
65+
from typing import Any
66+
67+
from pydantic import BaseModel, Field, ValidationError, constr
68+
69+
from flagpole.conditions import Segment
70+
from flagpole.evaluation_context import ContextBuilder, EvaluationContext
71+
from sentry.utils import json
72+
73+
74+
class InvalidFeatureFlagConfiguration(Exception):
75+
pass
76+
77+
78+
class Feature(BaseModel):
79+
name: constr(min_length=1, to_lower=True) # type:ignore[valid-type]
80+
owner: constr(min_length=1) # type:ignore[valid-type]
81+
segments: list[Segment]
82+
"""A list of segments to evaluate against the provided data"""
83+
enabled: bool = True
84+
"""Defines whether or not the feature is enabled."""
85+
created_at: datetime = Field(default_factory=datetime.now)
86+
"""This datetime is when this instance was created. It can be used to decide when to re-read configuration data"""
87+
88+
def match(self, context: EvaluationContext) -> bool:
89+
if self.enabled:
90+
for segment in self.segments:
91+
if segment.match(context):
92+
return True
93+
94+
return False
95+
96+
def dump_schema_to_file(self, file_path: str) -> None:
97+
with open(file_path, "w") as file:
98+
file.write(self.schema_json())
99+
100+
@classmethod
101+
def from_feature_dictionary(cls, name: str, config_dict: dict[str, Any]) -> Feature:
102+
try:
103+
feature = cls(name=name, **config_dict)
104+
except ValidationError as exc:
105+
raise InvalidFeatureFlagConfiguration("Provided JSON is not a valid feature") from exc
106+
107+
return feature
108+
109+
@classmethod
110+
def from_feature_config_json(
111+
cls, name: str, config_json: str, context_builder: ContextBuilder | None = None
112+
) -> Feature:
113+
try:
114+
config_data_dict = json.loads(config_json)
115+
except json.JSONDecodeError as decode_error:
116+
raise InvalidFeatureFlagConfiguration("Invalid feature json provided") from decode_error
117+
118+
if not isinstance(config_data_dict, dict):
119+
raise InvalidFeatureFlagConfiguration("Feature JSON is not a valid feature")
120+
121+
return cls.from_feature_dictionary(name=name, config_dict=config_data_dict)
122+
123+
124+
__all__ = ["Feature", "InvalidFeatureFlagConfiguration", "ContextBuilder", "EvaluationContext"]

src/flagpole/conditions.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from pydantic import BaseModel, constr
2+
3+
from flagpole.evaluation_context import EvaluationContext
4+
from flagpole.operators import AvailableOperators
5+
6+
7+
class Condition(BaseModel):
8+
property: str | None
9+
operator: AvailableOperators
10+
11+
def match(self, context: EvaluationContext, segment_name: str) -> bool:
12+
if self.property is None:
13+
return False
14+
15+
return self.operator.match(
16+
condition_property=context.get(self.property), segment_name=segment_name
17+
)
18+
19+
20+
class Segment(BaseModel):
21+
name: constr(min_length=1) # type:ignore[valid-type]
22+
conditions: list[Condition]
23+
rollout: int | None = 0
24+
25+
def match(self, context: EvaluationContext) -> bool:
26+
for condition in self.conditions:
27+
match_condition = condition.match(context, segment_name=self.name)
28+
if not match_condition:
29+
return False
30+
# Apply incremental rollout if available.
31+
if self.rollout is not None and self.rollout < 100:
32+
return context.id() % 100 <= self.rollout
33+
34+
return True

src/flagpole/evaluation_context.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
from collections.abc import Callable
5+
from copy import deepcopy
6+
from typing import Any, TypeVar
7+
8+
from pydantic import BaseModel
9+
10+
ValidContextTypes = TypeVar(
11+
"ValidContextTypes",
12+
bound=str | int | float | bool | list[str] | list[int] | list[float] | list[bool],
13+
)
14+
15+
EvaluationContextDict = dict[str, ValidContextTypes]
16+
17+
18+
class EvaluationContext:
19+
"""
20+
Prepared by the application and passed to flagpole to evaluate
21+
feature conditions.
22+
"""
23+
24+
def __init__(self, data: EvaluationContextDict):
25+
self.__data = deepcopy(data)
26+
27+
def get(self, key: str) -> Any:
28+
return self.__data.get(key)
29+
30+
def has(self, key: str) -> Any:
31+
return key in self.__data
32+
33+
def size(self) -> int:
34+
return len(self.__data)
35+
36+
def id(self) -> int:
37+
"""
38+
Return a hashed identifier for this context
39+
40+
The identifier should be stable for a given context contents.
41+
Identifiers are used to determine rollout groups deterministically
42+
and consistently.
43+
"""
44+
keys = self.__data.keys()
45+
vector = []
46+
for key in sorted(keys):
47+
vector.append(key)
48+
vector.append(str(self.__data[key]))
49+
hashed = hashlib.sha1(":".join(vector).encode("utf8"))
50+
return int.from_bytes(hashed.digest(), byteorder="big")
51+
52+
53+
# A function that generates a new slice of evaluation context data as a dictionary.
54+
EvaluationContextTransformer = Callable[[dict[str, Any]], EvaluationContextDict]
55+
56+
57+
class ContextBuilder(BaseModel):
58+
"""
59+
Used to build an EvaluationContext instance for use in Flagpole.
60+
This class aggregates a list of context transformers, each of which are
61+
responsible for generating a slice of context data.
62+
63+
This class is meant to be used with Flagpole's `Feature` class:
64+
>>> from flagpole import ContextBuilder, Feature
65+
>>> builder = ContextBuilder().add_context_transformer(lambda _dict: dict(foo="bar"))
66+
>>> feature = Feature.from_feature_dictionary(name="foo", feature_dictionary=dict(), context=builder)
67+
>>> feature.match(dict())
68+
"""
69+
70+
context_transformers: list[EvaluationContextTransformer] = []
71+
exception_handler: Callable[[Exception], Any] | None
72+
73+
def add_context_transformer(
74+
self, context_transformer: EvaluationContextTransformer
75+
) -> ContextBuilder:
76+
self.context_transformers.append(context_transformer)
77+
return self
78+
79+
def add_exception_handler(self, exception_handler: Callable[[Exception], None]):
80+
"""
81+
Add a custom exception handler to the context builder if you need custom handling
82+
if any of the transformer functions raise an exception. This is useful for swallowing
83+
or reporting any exceptions that occur while building a context.
84+
85+
:param exception_handler:
86+
"""
87+
if self.exception_handler is not None:
88+
raise Exception("Exception handler is already defined")
89+
90+
self.exception_handler = exception_handler
91+
92+
def build(self, data: dict[str, Any] | None = None) -> EvaluationContext:
93+
builder_data: dict[str, Any] = data or dict()
94+
context_data: dict[str, Any] = dict()
95+
96+
for transformer in self.context_transformers:
97+
try:
98+
context_data = {**context_data, **transformer(builder_data)}
99+
except Exception as e:
100+
if self.exception_handler is not None:
101+
self.exception_handler(e)
102+
else:
103+
raise
104+
105+
return EvaluationContext(context_data)

0 commit comments

Comments
 (0)