flagsmith-flag-engine 6.0.1__py3-none-any.whl
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.
- flag_engine/__init__.py +0 -0
- flag_engine/context/__init__.py +0 -0
- flag_engine/context/mappers.py +39 -0
- flag_engine/context/types.py +28 -0
- flag_engine/engine.py +146 -0
- flag_engine/environments/__init__.py +0 -0
- flag_engine/environments/integrations/__init__.py +0 -0
- flag_engine/environments/integrations/models.py +9 -0
- flag_engine/environments/models.py +95 -0
- flag_engine/features/__init__.py +0 -0
- flag_engine/features/constants.py +3 -0
- flag_engine/features/models.py +159 -0
- flag_engine/identities/__init__.py +0 -0
- flag_engine/identities/models.py +93 -0
- flag_engine/identities/traits/__init__.py +0 -0
- flag_engine/identities/traits/constants.py +1 -0
- flag_engine/identities/traits/models.py +8 -0
- flag_engine/identities/traits/types.py +62 -0
- flag_engine/organisations/__init__.py +0 -0
- flag_engine/organisations/models.py +13 -0
- flag_engine/projects/__init__.py +0 -0
- flag_engine/projects/models.py +16 -0
- flag_engine/py.typed +0 -0
- flag_engine/segments/__init__.py +0 -0
- flag_engine/segments/constants.py +22 -0
- flag_engine/segments/evaluator.py +233 -0
- flag_engine/segments/models.py +41 -0
- flag_engine/segments/types.py +24 -0
- flag_engine/types/__init__.py +0 -0
- flag_engine/utils/__init__.py +0 -0
- flag_engine/utils/datetime.py +5 -0
- flag_engine/utils/exceptions.py +10 -0
- flag_engine/utils/hashing.py +33 -0
- flag_engine/utils/json/__init__.py +0 -0
- flag_engine/utils/json/encoders.py +16 -0
- flag_engine/utils/semver.py +26 -0
- flag_engine/utils/types.py +46 -0
- flagsmith_flag_engine-6.0.1.dist-info/METADATA +50 -0
- flagsmith_flag_engine-6.0.1.dist-info/RECORD +42 -0
- flagsmith_flag_engine-6.0.1.dist-info/WHEEL +5 -0
- flagsmith_flag_engine-6.0.1.dist-info/licenses/LICENSE.txt +12 -0
- flagsmith_flag_engine-6.0.1.dist-info/top_level.txt +1 -0
flag_engine/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from flag_engine.context.types import EvaluationContext
|
|
4
|
+
from flag_engine.environments.models import EnvironmentModel
|
|
5
|
+
from flag_engine.identities.models import IdentityModel
|
|
6
|
+
from flag_engine.identities.traits.models import TraitModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def map_environment_identity_to_context(
|
|
10
|
+
environment: EnvironmentModel,
|
|
11
|
+
identity: IdentityModel,
|
|
12
|
+
override_traits: typing.Optional[typing.List[TraitModel]],
|
|
13
|
+
) -> EvaluationContext:
|
|
14
|
+
"""
|
|
15
|
+
Maps an EnvironmentModel and IdentityModel to an EvaluationContext.
|
|
16
|
+
|
|
17
|
+
:param environment: The environment model object.
|
|
18
|
+
:param identity: The identity model object.
|
|
19
|
+
:param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided.
|
|
20
|
+
:return: An EvaluationContext containing the environment and identity.
|
|
21
|
+
"""
|
|
22
|
+
return {
|
|
23
|
+
"environment": {
|
|
24
|
+
"key": environment.api_key,
|
|
25
|
+
"name": environment.name or "",
|
|
26
|
+
},
|
|
27
|
+
"identity": {
|
|
28
|
+
"identifier": identity.identifier,
|
|
29
|
+
"key": str(identity.django_id or identity.composite_key),
|
|
30
|
+
"traits": {
|
|
31
|
+
trait.trait_key: trait.trait_value
|
|
32
|
+
for trait in (
|
|
33
|
+
override_traits
|
|
34
|
+
if override_traits is not None
|
|
35
|
+
else identity.identity_traits
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# generated by datamodel-codegen:
|
|
2
|
+
# filename: https://raw.githubusercontent.com/Flagsmith/flagsmith/chore/update-evaluation-context/sdk/evaluation-context.json # noqa: E501
|
|
3
|
+
# timestamp: 2025-07-16T10:39:10+00:00
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Dict, Optional, TypedDict
|
|
8
|
+
|
|
9
|
+
from typing_extensions import NotRequired
|
|
10
|
+
|
|
11
|
+
from flag_engine.identities.traits.types import ContextValue
|
|
12
|
+
from flag_engine.utils.types import SupportsStr
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EnvironmentContext(TypedDict):
|
|
16
|
+
key: str
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IdentityContext(TypedDict):
|
|
21
|
+
identifier: str
|
|
22
|
+
key: SupportsStr
|
|
23
|
+
traits: NotRequired[Dict[str, ContextValue]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EvaluationContext(TypedDict):
|
|
27
|
+
environment: EnvironmentContext
|
|
28
|
+
identity: NotRequired[Optional[IdentityContext]]
|
flag_engine/engine.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from flag_engine.context.mappers import map_environment_identity_to_context
|
|
4
|
+
from flag_engine.context.types import EvaluationContext
|
|
5
|
+
from flag_engine.environments.models import EnvironmentModel
|
|
6
|
+
from flag_engine.features.models import FeatureModel, FeatureStateModel
|
|
7
|
+
from flag_engine.identities.models import IdentityModel
|
|
8
|
+
from flag_engine.identities.traits.models import TraitModel
|
|
9
|
+
from flag_engine.segments.evaluator import get_context_segments
|
|
10
|
+
from flag_engine.utils.exceptions import FeatureStateNotFound
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_environment_feature_states(
|
|
14
|
+
environment: EnvironmentModel,
|
|
15
|
+
) -> typing.List[FeatureStateModel]:
|
|
16
|
+
"""
|
|
17
|
+
Get a list of feature states for a given environment
|
|
18
|
+
|
|
19
|
+
:param environment: the environment model object
|
|
20
|
+
"""
|
|
21
|
+
if environment.get_hide_disabled_flags():
|
|
22
|
+
return [fs for fs in environment.feature_states if fs.enabled]
|
|
23
|
+
return environment.feature_states
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_environment_feature_state(
|
|
27
|
+
environment: EnvironmentModel, feature_name: str
|
|
28
|
+
) -> FeatureStateModel:
|
|
29
|
+
"""
|
|
30
|
+
Get a specific feature state for a given feature_name in a given environment
|
|
31
|
+
|
|
32
|
+
:param environment: the environment model object
|
|
33
|
+
:param feature_name: the name of the feature to get the feature state for
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
return next(
|
|
37
|
+
filter(lambda f: f.feature.name == feature_name, environment.feature_states)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
except StopIteration:
|
|
41
|
+
raise FeatureStateNotFound()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_identity_feature_states(
|
|
45
|
+
environment: EnvironmentModel,
|
|
46
|
+
identity: IdentityModel,
|
|
47
|
+
override_traits: typing.Optional[typing.List[TraitModel]] = None,
|
|
48
|
+
) -> typing.List[FeatureStateModel]:
|
|
49
|
+
"""
|
|
50
|
+
Get a list of feature states for a given identity in a given environment.
|
|
51
|
+
|
|
52
|
+
:param environment: the environment model object the identity belongs to
|
|
53
|
+
:param identity: the identity model object to get the feature state for
|
|
54
|
+
:param override_traits: optionally override the traits on the identity model
|
|
55
|
+
:return: list of feature state models based on the environment, any matching
|
|
56
|
+
segments and any specific identity overrides
|
|
57
|
+
"""
|
|
58
|
+
context = map_environment_identity_to_context(
|
|
59
|
+
environment=environment,
|
|
60
|
+
identity=identity,
|
|
61
|
+
override_traits=override_traits,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
feature_states = list(
|
|
65
|
+
_get_identity_feature_states_dict(
|
|
66
|
+
environment=environment,
|
|
67
|
+
identity=identity,
|
|
68
|
+
context=context,
|
|
69
|
+
).values()
|
|
70
|
+
)
|
|
71
|
+
if environment.get_hide_disabled_flags():
|
|
72
|
+
return [fs for fs in feature_states if fs.enabled]
|
|
73
|
+
return feature_states
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_identity_feature_state(
|
|
77
|
+
environment: EnvironmentModel,
|
|
78
|
+
identity: IdentityModel,
|
|
79
|
+
feature_name: str,
|
|
80
|
+
override_traits: typing.Optional[typing.List[TraitModel]] = None,
|
|
81
|
+
) -> FeatureStateModel:
|
|
82
|
+
"""
|
|
83
|
+
Get a specific feature state for a given identity in a given environment.
|
|
84
|
+
|
|
85
|
+
:param environment: the environment model object the identity belongs to
|
|
86
|
+
:param identity: the identity model object to get the feature state for
|
|
87
|
+
:param feature_name: the name of the feature to get the feature state for
|
|
88
|
+
:param override_traits: optionally override the traits on the identity model
|
|
89
|
+
:return: feature state model based on the environment, any matching
|
|
90
|
+
segments and any specific identity overrides
|
|
91
|
+
"""
|
|
92
|
+
context = map_environment_identity_to_context(
|
|
93
|
+
environment=environment,
|
|
94
|
+
identity=identity,
|
|
95
|
+
override_traits=override_traits,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
feature_states = _get_identity_feature_states_dict(
|
|
99
|
+
environment=environment,
|
|
100
|
+
identity=identity,
|
|
101
|
+
context=context,
|
|
102
|
+
)
|
|
103
|
+
matching_feature = next(
|
|
104
|
+
filter(lambda feature: feature.name == feature_name, feature_states.keys()),
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not matching_feature:
|
|
109
|
+
raise FeatureStateNotFound()
|
|
110
|
+
|
|
111
|
+
return feature_states[matching_feature]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_identity_feature_states_dict(
|
|
115
|
+
environment: EnvironmentModel,
|
|
116
|
+
identity: IdentityModel,
|
|
117
|
+
context: EvaluationContext,
|
|
118
|
+
) -> typing.Dict[FeatureModel, FeatureStateModel]:
|
|
119
|
+
# Get feature states from the environment
|
|
120
|
+
feature_states_by_feature = {fs.feature: fs for fs in environment.feature_states}
|
|
121
|
+
|
|
122
|
+
# Override with any feature states defined by matching segments
|
|
123
|
+
for context_segment in get_context_segments(
|
|
124
|
+
context=context,
|
|
125
|
+
segments=environment.project.segments,
|
|
126
|
+
):
|
|
127
|
+
for segment_feature_state in context_segment.feature_states:
|
|
128
|
+
if (
|
|
129
|
+
feature_state := feature_states_by_feature.get(
|
|
130
|
+
segment_feature := segment_feature_state.feature
|
|
131
|
+
)
|
|
132
|
+
) and feature_state.is_higher_segment_priority(segment_feature_state):
|
|
133
|
+
continue
|
|
134
|
+
feature_states_by_feature[segment_feature] = segment_feature_state
|
|
135
|
+
|
|
136
|
+
# Override with any feature states defined directly the identity
|
|
137
|
+
feature_states_by_feature.update(
|
|
138
|
+
{
|
|
139
|
+
identity_feature: identity_feature_state
|
|
140
|
+
for identity_feature_state in identity.identity_features
|
|
141
|
+
if (identity_feature := identity_feature_state.feature)
|
|
142
|
+
in feature_states_by_feature
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return feature_states_by_feature
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from flag_engine.environments.integrations.models import IntegrationModel
|
|
7
|
+
from flag_engine.features.models import FeatureStateModel
|
|
8
|
+
from flag_engine.identities.models import IdentityModel
|
|
9
|
+
from flag_engine.projects.models import ProjectModel
|
|
10
|
+
from flag_engine.utils.datetime import utcnow_with_tz
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnvironmentAPIKeyModel(BaseModel):
|
|
14
|
+
id: int
|
|
15
|
+
key: str
|
|
16
|
+
created_at: datetime
|
|
17
|
+
name: str
|
|
18
|
+
client_api_key: str
|
|
19
|
+
expires_at: typing.Optional[datetime] = None
|
|
20
|
+
active: bool = True
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_valid(self) -> bool:
|
|
24
|
+
return self.active and (
|
|
25
|
+
not self.expires_at or self.expires_at > utcnow_with_tz()
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebhookModel(BaseModel):
|
|
30
|
+
url: str
|
|
31
|
+
secret: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EnvironmentModel(BaseModel):
|
|
35
|
+
id: int
|
|
36
|
+
api_key: str
|
|
37
|
+
project: ProjectModel
|
|
38
|
+
feature_states: typing.List[FeatureStateModel] = Field(default_factory=list)
|
|
39
|
+
identity_overrides: typing.List[IdentityModel] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
name: typing.Optional[str] = None
|
|
42
|
+
allow_client_traits: bool = True
|
|
43
|
+
updated_at: datetime = Field(default_factory=utcnow_with_tz)
|
|
44
|
+
hide_sensitive_data: bool = False
|
|
45
|
+
hide_disabled_flags: typing.Optional[bool] = None
|
|
46
|
+
use_identity_composite_key_for_hashing: bool = False
|
|
47
|
+
use_identity_overrides_in_local_eval: bool = False
|
|
48
|
+
|
|
49
|
+
amplitude_config: typing.Optional[IntegrationModel] = None
|
|
50
|
+
dynatrace_config: typing.Optional[IntegrationModel] = None
|
|
51
|
+
heap_config: typing.Optional[IntegrationModel] = None
|
|
52
|
+
mixpanel_config: typing.Optional[IntegrationModel] = None
|
|
53
|
+
rudderstack_config: typing.Optional[IntegrationModel] = None
|
|
54
|
+
segment_config: typing.Optional[IntegrationModel] = None
|
|
55
|
+
|
|
56
|
+
webhook_config: typing.Optional[WebhookModel] = None
|
|
57
|
+
|
|
58
|
+
_INTEGRATION_ATTRS = [
|
|
59
|
+
"amplitude_config",
|
|
60
|
+
"heap_config",
|
|
61
|
+
"mixpanel_config",
|
|
62
|
+
"rudderstack_config",
|
|
63
|
+
"segment_config",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def integrations_data(self) -> typing.Dict[str, typing.Dict[str, str]]:
|
|
68
|
+
"""
|
|
69
|
+
Return a dictionary representation of all integration config objects.
|
|
70
|
+
|
|
71
|
+
e.g.
|
|
72
|
+
{
|
|
73
|
+
"mixpanel_config": {"base_url": None, "api_key": "some-key"},
|
|
74
|
+
"segment_config": {
|
|
75
|
+
"base_url": "https://api.segment.com",
|
|
76
|
+
"api_key": "some-key",
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
integrations_data = {}
|
|
82
|
+
for integration_attr in self._INTEGRATION_ATTRS:
|
|
83
|
+
integration_config: typing.Optional[IntegrationModel]
|
|
84
|
+
if integration_config := getattr(self, integration_attr, None):
|
|
85
|
+
integrations_data[integration_attr] = {
|
|
86
|
+
"base_url": integration_config.base_url,
|
|
87
|
+
"api_key": integration_config.api_key,
|
|
88
|
+
"entity_selector": integration_config.entity_selector,
|
|
89
|
+
}
|
|
90
|
+
return integrations_data
|
|
91
|
+
|
|
92
|
+
def get_hide_disabled_flags(self) -> bool:
|
|
93
|
+
if self.hide_disabled_flags is not None:
|
|
94
|
+
return self.hide_disabled_flags
|
|
95
|
+
return self.project.hide_disabled_flags
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import typing
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from annotated_types import Ge, Le, SupportsLt
|
|
6
|
+
from pydantic import UUID4, BaseModel, Field, model_validator
|
|
7
|
+
from pydantic_collections import BaseCollectionModel
|
|
8
|
+
from typing_extensions import Annotated
|
|
9
|
+
|
|
10
|
+
from flag_engine.utils.exceptions import InvalidPercentageAllocation
|
|
11
|
+
from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FeatureModel(BaseModel):
|
|
15
|
+
id: int
|
|
16
|
+
name: str
|
|
17
|
+
type: str
|
|
18
|
+
|
|
19
|
+
def __eq__(self, other: object) -> bool:
|
|
20
|
+
return isinstance(other, FeatureModel) and self.id == other.id
|
|
21
|
+
|
|
22
|
+
def __hash__(self) -> int:
|
|
23
|
+
return hash(self.id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MultivariateFeatureOptionModel(BaseModel):
|
|
27
|
+
value: typing.Any
|
|
28
|
+
id: typing.Optional[int] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MultivariateFeatureStateValueModel(BaseModel):
|
|
32
|
+
multivariate_feature_option: MultivariateFeatureOptionModel
|
|
33
|
+
percentage_allocation: Annotated[float, Ge(0), Le(100)]
|
|
34
|
+
id: typing.Optional[int] = None
|
|
35
|
+
mv_fs_value_uuid: UUID4 = Field(default_factory=uuid.uuid4)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FeatureSegmentModel(BaseModel):
|
|
39
|
+
priority: typing.Optional[int] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MultivariateFeatureStateValueList(
|
|
43
|
+
BaseCollectionModel[MultivariateFeatureStateValueModel] # type: ignore[misc,no-any-unimported]
|
|
44
|
+
):
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _ensure_correct_percentage_allocations(
|
|
47
|
+
value: typing.List[MultivariateFeatureStateValueModel],
|
|
48
|
+
) -> typing.List[MultivariateFeatureStateValueModel]:
|
|
49
|
+
if (
|
|
50
|
+
sum(
|
|
51
|
+
multivariate_feature_state.percentage_allocation
|
|
52
|
+
for multivariate_feature_state in value
|
|
53
|
+
)
|
|
54
|
+
> 100
|
|
55
|
+
):
|
|
56
|
+
raise InvalidPercentageAllocation(
|
|
57
|
+
"Total percentage allocation for feature must be less or equal to 100 percent"
|
|
58
|
+
)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
percentage_allocations_model_validator = model_validator(mode="after")(
|
|
62
|
+
_ensure_correct_percentage_allocations
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def append(
|
|
66
|
+
self,
|
|
67
|
+
multivariate_feature_state_value: MultivariateFeatureStateValueModel,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._ensure_correct_percentage_allocations(
|
|
70
|
+
[*self, multivariate_feature_state_value],
|
|
71
|
+
)
|
|
72
|
+
super().append(multivariate_feature_state_value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FeatureStateModel(BaseModel, validate_assignment=True):
|
|
76
|
+
feature: FeatureModel
|
|
77
|
+
enabled: bool
|
|
78
|
+
django_id: typing.Optional[int] = None
|
|
79
|
+
feature_segment: typing.Optional[FeatureSegmentModel] = None
|
|
80
|
+
featurestate_uuid: UUID4 = Field(default_factory=uuid.uuid4)
|
|
81
|
+
feature_state_value: typing.Any = None
|
|
82
|
+
multivariate_feature_state_values: MultivariateFeatureStateValueList = Field(
|
|
83
|
+
default_factory=MultivariateFeatureStateValueList
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def set_value(self, value: typing.Any) -> None:
|
|
87
|
+
self.feature_state_value = value
|
|
88
|
+
|
|
89
|
+
def get_value(self, identity_id: typing.Union[None, int, str] = None) -> typing.Any:
|
|
90
|
+
"""
|
|
91
|
+
Get the value of the feature state.
|
|
92
|
+
|
|
93
|
+
:param identity_id: a unique identifier for the identity, can be either a
|
|
94
|
+
numeric id or a string but must be unique for the identity.
|
|
95
|
+
:return: the value of the feature state.
|
|
96
|
+
"""
|
|
97
|
+
if identity_id and len(self.multivariate_feature_state_values) > 0:
|
|
98
|
+
return self._get_multivariate_value(identity_id)
|
|
99
|
+
return self.feature_state_value
|
|
100
|
+
|
|
101
|
+
def is_higher_segment_priority(self, other: "FeatureStateModel") -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Returns `True` if `self` is higher segment priority than `other`
|
|
104
|
+
(i.e. has lower value for feature_segment.priority)
|
|
105
|
+
|
|
106
|
+
NOTE:
|
|
107
|
+
A segment will be considered higher priority only if:
|
|
108
|
+
1. `other` does not have a feature segment(i.e: it is an environment feature state or it's a
|
|
109
|
+
feature state with feature segment but from an old document that does not have `feature_segment.priority`)
|
|
110
|
+
but `self` does.
|
|
111
|
+
|
|
112
|
+
2. `other` have a feature segment with high priority
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
if other_feature_segment := other.feature_segment:
|
|
117
|
+
if (
|
|
118
|
+
other_feature_segment_priority := other_feature_segment.priority
|
|
119
|
+
) is not None:
|
|
120
|
+
return (
|
|
121
|
+
getattr(
|
|
122
|
+
self.feature_segment,
|
|
123
|
+
"priority",
|
|
124
|
+
math.inf,
|
|
125
|
+
)
|
|
126
|
+
< other_feature_segment_priority
|
|
127
|
+
)
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def _get_multivariate_value(
|
|
131
|
+
self, identity_id: typing.Union[int, str]
|
|
132
|
+
) -> typing.Any:
|
|
133
|
+
percentage_value = get_hashed_percentage_for_object_ids(
|
|
134
|
+
[self.django_id or self.featurestate_uuid, identity_id]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Iterate over the mv options in order of id (so we get the same value each
|
|
138
|
+
# time) to determine the correct value to return to the identity based on
|
|
139
|
+
# the percentage allocations of the multivariate options. This gives us a
|
|
140
|
+
# way to ensure that the same value is returned every time we use the same
|
|
141
|
+
# percentage value.
|
|
142
|
+
start_percentage = 0.0
|
|
143
|
+
|
|
144
|
+
def _mv_fs_sort_key(mv_value: MultivariateFeatureStateValueModel) -> SupportsLt:
|
|
145
|
+
return mv_value.id or mv_value.mv_fs_value_uuid
|
|
146
|
+
|
|
147
|
+
for mv_value in sorted(
|
|
148
|
+
self.multivariate_feature_state_values,
|
|
149
|
+
key=_mv_fs_sort_key,
|
|
150
|
+
):
|
|
151
|
+
limit = mv_value.percentage_allocation + start_percentage
|
|
152
|
+
if start_percentage <= percentage_value < limit:
|
|
153
|
+
return mv_value.multivariate_feature_option.value
|
|
154
|
+
|
|
155
|
+
start_percentage = limit
|
|
156
|
+
|
|
157
|
+
# default to return the control value if no MV values found, although this
|
|
158
|
+
# should never happen
|
|
159
|
+
return self.feature_state_value
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import typing
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from pydantic import UUID4, BaseModel, Field, computed_field, model_validator
|
|
6
|
+
from pydantic_collections import BaseCollectionModel
|
|
7
|
+
|
|
8
|
+
from flag_engine.features.models import FeatureStateModel
|
|
9
|
+
from flag_engine.identities.traits.models import TraitModel
|
|
10
|
+
from flag_engine.utils.datetime import utcnow_with_tz
|
|
11
|
+
from flag_engine.utils.exceptions import DuplicateFeatureState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IdentityFeaturesList(BaseCollectionModel[FeatureStateModel]): # type: ignore[misc,no-any-unimported]
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _ensure_unique_feature_ids(
|
|
17
|
+
value: typing.Sequence[FeatureStateModel],
|
|
18
|
+
) -> None:
|
|
19
|
+
for i, feature_state in enumerate(value, start=1):
|
|
20
|
+
if feature_state.feature.id in [
|
|
21
|
+
feature_state.feature.id for feature_state in value[i:]
|
|
22
|
+
]:
|
|
23
|
+
raise DuplicateFeatureState(
|
|
24
|
+
f"Feature state for feature id={feature_state.feature.id} already exists"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@model_validator(mode="after")
|
|
28
|
+
def ensure_unique_feature_ids(self) -> "IdentityFeaturesList":
|
|
29
|
+
self._ensure_unique_feature_ids(self.root)
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def append(self, feature_state: "FeatureStateModel") -> None:
|
|
33
|
+
self._ensure_unique_feature_ids([*self, feature_state])
|
|
34
|
+
super().append(feature_state)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IdentityModel(BaseModel):
|
|
38
|
+
identifier: str
|
|
39
|
+
environment_api_key: str
|
|
40
|
+
created_date: datetime.datetime = Field(default_factory=utcnow_with_tz)
|
|
41
|
+
identity_features: IdentityFeaturesList = Field(
|
|
42
|
+
default_factory=IdentityFeaturesList
|
|
43
|
+
)
|
|
44
|
+
identity_traits: typing.List[TraitModel] = Field(default_factory=list)
|
|
45
|
+
identity_uuid: UUID4 = Field(default_factory=uuid.uuid4)
|
|
46
|
+
django_id: typing.Optional[int] = None
|
|
47
|
+
|
|
48
|
+
dashboard_alias: typing.Optional[str] = None
|
|
49
|
+
|
|
50
|
+
@computed_field # type: ignore[misc]
|
|
51
|
+
@property
|
|
52
|
+
def composite_key(self) -> str:
|
|
53
|
+
return self.generate_composite_key(self.environment_api_key, self.identifier)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def generate_composite_key(env_key: str, identifier: str) -> str:
|
|
57
|
+
return f"{env_key}_{identifier}"
|
|
58
|
+
|
|
59
|
+
def get_hash_key(self, use_identity_composite_key_for_hashing: bool) -> str:
|
|
60
|
+
return (
|
|
61
|
+
self.composite_key
|
|
62
|
+
if use_identity_composite_key_for_hashing
|
|
63
|
+
else self.identifier
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def update_traits(
|
|
67
|
+
self, traits: typing.List[TraitModel]
|
|
68
|
+
) -> typing.Tuple[typing.List[TraitModel], bool]:
|
|
69
|
+
existing_traits = {trait.trait_key: trait for trait in self.identity_traits}
|
|
70
|
+
traits_changed = False
|
|
71
|
+
|
|
72
|
+
for trait in traits:
|
|
73
|
+
existing_trait = existing_traits.get(trait.trait_key)
|
|
74
|
+
|
|
75
|
+
if trait.trait_value is None and existing_trait:
|
|
76
|
+
existing_traits.pop(trait.trait_key)
|
|
77
|
+
traits_changed = True
|
|
78
|
+
|
|
79
|
+
elif getattr(existing_trait, "trait_value", None) != trait.trait_value:
|
|
80
|
+
existing_traits[trait.trait_key] = trait
|
|
81
|
+
traits_changed = True
|
|
82
|
+
|
|
83
|
+
self.identity_traits = list(existing_traits.values())
|
|
84
|
+
return self.identity_traits, traits_changed
|
|
85
|
+
|
|
86
|
+
def prune_features(self, valid_feature_names: typing.List[str]) -> None:
|
|
87
|
+
self.identity_features = IdentityFeaturesList(
|
|
88
|
+
[
|
|
89
|
+
fs
|
|
90
|
+
for fs in self.identity_features
|
|
91
|
+
if fs.feature.name in valid_feature_names
|
|
92
|
+
]
|
|
93
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
TRAIT_STRING_VALUE_MAX_LENGTH: int = 2000
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from typing import Any, Union, get_args
|
|
4
|
+
|
|
5
|
+
from pydantic import BeforeValidator
|
|
6
|
+
from pydantic.types import AllowInfNan, StrictBool, StringConstraints
|
|
7
|
+
from typing_extensions import Annotated, TypeGuard
|
|
8
|
+
|
|
9
|
+
from flag_engine.identities.traits.constants import TRAIT_STRING_VALUE_MAX_LENGTH
|
|
10
|
+
|
|
11
|
+
_UnconstrainedContextValue = Union[None, int, float, bool, str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def map_any_value_to_trait_value(value: Any) -> _UnconstrainedContextValue:
|
|
15
|
+
"""
|
|
16
|
+
Try to coerce a value of arbitrary type to a trait value type.
|
|
17
|
+
Union member-specific constraints, such as max string value length, are ignored here.
|
|
18
|
+
Replicate behaviour from marshmallow/pydantic V1 for number-like strings.
|
|
19
|
+
For decimals return an int in case of unset exponent.
|
|
20
|
+
When in doubt, return string.
|
|
21
|
+
|
|
22
|
+
Supposed to be used as a `pydantic.BeforeValidator`.
|
|
23
|
+
"""
|
|
24
|
+
if _is_trait_value(value):
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return _map_string_value_to_trait_value(value)
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, Decimal):
|
|
29
|
+
if value.as_tuple().exponent:
|
|
30
|
+
return float(str(value))
|
|
31
|
+
return int(value)
|
|
32
|
+
return str(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_int_pattern = re.compile(r"-?[0-9]+")
|
|
36
|
+
_float_pattern = re.compile(r"-?[0-9]+\.[0-9]+")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _map_string_value_to_trait_value(value: str) -> _UnconstrainedContextValue:
|
|
40
|
+
if _int_pattern.fullmatch(value):
|
|
41
|
+
return int(value)
|
|
42
|
+
if _float_pattern.fullmatch(value):
|
|
43
|
+
return float(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_trait_value(value: Any) -> TypeGuard[_UnconstrainedContextValue]:
|
|
48
|
+
return isinstance(value, get_args(_UnconstrainedContextValue))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ContextValue = Annotated[
|
|
52
|
+
Union[
|
|
53
|
+
None,
|
|
54
|
+
StrictBool,
|
|
55
|
+
Annotated[float, AllowInfNan(False)],
|
|
56
|
+
int,
|
|
57
|
+
Annotated[str, StringConstraints(max_length=TRAIT_STRING_VALUE_MAX_LENGTH)],
|
|
58
|
+
],
|
|
59
|
+
BeforeValidator(map_any_value_to_trait_value),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
TraitValue = ContextValue
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OrganisationModel(BaseModel):
|
|
5
|
+
id: int
|
|
6
|
+
name: str
|
|
7
|
+
feature_analytics: bool = False
|
|
8
|
+
stop_serving_flags: bool = False
|
|
9
|
+
persist_trait_data: bool = True
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def unique_slug(self) -> str:
|
|
13
|
+
return str(self.id) + "-" + self.name
|
|
File without changes
|