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.
Files changed (42) hide show
  1. flag_engine/__init__.py +0 -0
  2. flag_engine/context/__init__.py +0 -0
  3. flag_engine/context/mappers.py +39 -0
  4. flag_engine/context/types.py +28 -0
  5. flag_engine/engine.py +146 -0
  6. flag_engine/environments/__init__.py +0 -0
  7. flag_engine/environments/integrations/__init__.py +0 -0
  8. flag_engine/environments/integrations/models.py +9 -0
  9. flag_engine/environments/models.py +95 -0
  10. flag_engine/features/__init__.py +0 -0
  11. flag_engine/features/constants.py +3 -0
  12. flag_engine/features/models.py +159 -0
  13. flag_engine/identities/__init__.py +0 -0
  14. flag_engine/identities/models.py +93 -0
  15. flag_engine/identities/traits/__init__.py +0 -0
  16. flag_engine/identities/traits/constants.py +1 -0
  17. flag_engine/identities/traits/models.py +8 -0
  18. flag_engine/identities/traits/types.py +62 -0
  19. flag_engine/organisations/__init__.py +0 -0
  20. flag_engine/organisations/models.py +13 -0
  21. flag_engine/projects/__init__.py +0 -0
  22. flag_engine/projects/models.py +16 -0
  23. flag_engine/py.typed +0 -0
  24. flag_engine/segments/__init__.py +0 -0
  25. flag_engine/segments/constants.py +22 -0
  26. flag_engine/segments/evaluator.py +233 -0
  27. flag_engine/segments/models.py +41 -0
  28. flag_engine/segments/types.py +24 -0
  29. flag_engine/types/__init__.py +0 -0
  30. flag_engine/utils/__init__.py +0 -0
  31. flag_engine/utils/datetime.py +5 -0
  32. flag_engine/utils/exceptions.py +10 -0
  33. flag_engine/utils/hashing.py +33 -0
  34. flag_engine/utils/json/__init__.py +0 -0
  35. flag_engine/utils/json/encoders.py +16 -0
  36. flag_engine/utils/semver.py +26 -0
  37. flag_engine/utils/types.py +46 -0
  38. flagsmith_flag_engine-6.0.1.dist-info/METADATA +50 -0
  39. flagsmith_flag_engine-6.0.1.dist-info/RECORD +42 -0
  40. flagsmith_flag_engine-6.0.1.dist-info/WHEEL +5 -0
  41. flagsmith_flag_engine-6.0.1.dist-info/licenses/LICENSE.txt +12 -0
  42. flagsmith_flag_engine-6.0.1.dist-info/top_level.txt +1 -0
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,9 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class IntegrationModel(BaseModel):
7
+ api_key: Optional[str] = None
8
+ base_url: Optional[str] = None
9
+ entity_selector: Optional[str] = None
@@ -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,3 @@
1
+ # Feature Types
2
+ STANDARD = "STANDARD"
3
+ MULTIVARIATE = "MULTIVARIATE"
@@ -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,8 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from flag_engine.identities.traits.types import ContextValue
4
+
5
+
6
+ class TraitModel(BaseModel):
7
+ trait_key: str
8
+ trait_value: ContextValue = Field(...)
@@ -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