|
1 | | -import json |
| 1 | +import re |
2 | 2 | import typing |
3 | | -from collections import defaultdict |
| 3 | +from decimal import Decimal |
4 | 4 |
|
5 | | -from flag_engine.context.types import ( |
6 | | - EvaluationContext, |
7 | | - FeatureContext, |
8 | | - SegmentContext, |
9 | | - SegmentRule, |
10 | | -) |
11 | | -from flag_engine.environments.models import EnvironmentModel |
12 | | -from flag_engine.features.models import ( |
13 | | - FeatureModel, |
14 | | - FeatureStateModel, |
15 | | - MultivariateFeatureStateValueModel, |
16 | | -) |
17 | | -from flag_engine.identities.models import IdentityModel |
18 | | -from flag_engine.identities.traits.models import TraitModel |
19 | | -from flag_engine.result.types import FlagResult |
20 | | -from flag_engine.segments.models import SegmentRuleModel |
| 5 | +from flag_engine.segments.types import ContextValue, is_context_value |
21 | 6 |
|
22 | | -OverrideKey = typing.Tuple[ |
23 | | - str, |
24 | | - str, |
25 | | - bool, |
26 | | - typing.Any, |
27 | | -] |
28 | | -OverridesKey = typing.Tuple[OverrideKey, ...] |
29 | 7 |
|
30 | | - |
31 | | -def map_environment_identity_to_context( |
32 | | - environment: EnvironmentModel, |
33 | | - identity: typing.Optional[IdentityModel], |
34 | | - override_traits: typing.Optional[typing.List[TraitModel]], |
35 | | -) -> EvaluationContext: |
36 | | - """ |
37 | | - Map an EnvironmentModel and IdentityModel to an EvaluationContext. |
38 | | -
|
39 | | - :param environment: The environment model object. |
40 | | - :param identity: The identity model object. |
41 | | - :param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided. |
42 | | - :return: An EvaluationContext containing the environment and identity. |
43 | | - """ |
44 | | - features = _map_feature_states_to_feature_contexts(environment.feature_states) |
45 | | - segments: typing.Dict[str, SegmentContext] = {} |
46 | | - for segment in environment.project.segments: |
47 | | - segment_ctx_data: SegmentContext = { |
48 | | - "key": str(segment.id), |
49 | | - "name": segment.name, |
50 | | - "rules": _map_segment_rules_to_segment_context_rules(segment.rules), |
51 | | - } |
52 | | - if segment_feature_states := segment.feature_states: |
53 | | - segment_ctx_data["overrides"] = list( |
54 | | - _map_feature_states_to_feature_contexts(segment_feature_states).values() |
55 | | - ) |
56 | | - segments[str(segment.id)] = segment_ctx_data |
57 | | - identity_overrides = environment.identity_overrides + [identity] if identity else [] |
58 | | - segments.update(_map_identity_overrides_to_segment_contexts(identity_overrides)) |
59 | | - return { |
60 | | - "environment": { |
61 | | - "key": environment.api_key, |
62 | | - "name": environment.name or "", |
63 | | - }, |
64 | | - "identity": ( |
65 | | - { |
66 | | - "identifier": identity.identifier, |
67 | | - "key": str(identity.django_id or identity.composite_key), |
68 | | - "traits": { |
69 | | - trait.trait_key: trait.trait_value |
70 | | - for trait in ( |
71 | | - override_traits |
72 | | - if override_traits is not None |
73 | | - else identity.identity_traits |
74 | | - ) |
75 | | - }, |
76 | | - } |
77 | | - if identity |
78 | | - else None |
79 | | - ), |
80 | | - "features": features, |
81 | | - "segments": segments, |
82 | | - } |
83 | | - |
84 | | - |
85 | | -def _map_identity_overrides_to_segment_contexts( |
86 | | - identity_overrides: typing.List[IdentityModel], |
87 | | -) -> typing.Dict[str, SegmentContext]: |
88 | | - """ |
89 | | - Map identity overrides to segment contexts. |
90 | | -
|
91 | | - :param identity_overrides: A list of IdentityModel objects. |
92 | | - :return: A dictionary mapping segment ids to SegmentContext objects. |
93 | | - """ |
94 | | - features_to_identifiers: typing.Dict[ |
95 | | - OverridesKey, |
96 | | - typing.List[str], |
97 | | - ] = defaultdict(list) |
98 | | - for identity_override in identity_overrides: |
99 | | - identity_features: typing.List[FeatureStateModel] = ( |
100 | | - identity_override.identity_features |
101 | | - ) |
102 | | - if not identity_features: |
103 | | - continue |
104 | | - overrides_key = tuple( |
105 | | - ( |
106 | | - str(feature_state.feature.id), |
107 | | - feature_state.feature.name, |
108 | | - feature_state.enabled, |
109 | | - feature_state.feature_state_value, |
110 | | - ) |
111 | | - for feature_state in sorted(identity_features, key=_get_name) |
112 | | - ) |
113 | | - features_to_identifiers[overrides_key].append(identity_override.identifier) |
114 | | - segment_contexts: typing.Dict[str, SegmentContext] = {} |
115 | | - for overrides_key, identifiers in features_to_identifiers.items(): |
116 | | - # Create a segment context for each unique set of overrides |
117 | | - # Generate a unique key to avoid collisions |
118 | | - segment_key = str(hash(overrides_key)) |
119 | | - segment_contexts[segment_key] = SegmentContext( |
120 | | - key="", # Identity override segments never use % Split operator |
121 | | - name="identity_overrides", |
122 | | - rules=[ |
123 | | - { |
124 | | - "type": "ALL", |
125 | | - "conditions": [ |
126 | | - { |
127 | | - "property": "$.identity.identifier", |
128 | | - "operator": "IN", |
129 | | - "value": json.dumps(identifiers), |
130 | | - } |
131 | | - ], |
132 | | - } |
133 | | - ], |
134 | | - overrides=[ |
135 | | - { |
136 | | - "key": "", # Identity overrides never carry multivariate options |
137 | | - "feature_key": feature_key, |
138 | | - "name": feature_name, |
139 | | - "enabled": feature_enabled, |
140 | | - "value": feature_value, |
141 | | - "priority": float("-inf"), # Highest possible priority |
142 | | - } |
143 | | - for feature_key, feature_name, feature_enabled, feature_value in overrides_key |
144 | | - ], |
145 | | - ) |
146 | | - return segment_contexts |
147 | | - |
148 | | - |
149 | | -def _map_feature_states_to_feature_contexts( |
150 | | - feature_states: typing.List[FeatureStateModel], |
151 | | -) -> typing.Dict[str, FeatureContext]: |
152 | | - """ |
153 | | - Map feature states to feature contexts. |
154 | | -
|
155 | | - :param feature_states: A list of FeatureStateModel objects. |
156 | | - :return: A dictionary mapping feature names to their contexts. |
157 | | - """ |
158 | | - features: typing.Dict[str, FeatureContext] = {} |
159 | | - for feature_state in feature_states: |
160 | | - feature_context: FeatureContext = { |
161 | | - "key": str(feature_state.django_id or feature_state.featurestate_uuid), |
162 | | - "feature_key": str(feature_state.feature.id), |
163 | | - "name": feature_state.feature.name, |
164 | | - "enabled": feature_state.enabled, |
165 | | - "value": feature_state.feature_state_value, |
166 | | - } |
167 | | - multivariate_feature_state_values: typing.List[ |
168 | | - MultivariateFeatureStateValueModel |
169 | | - ] |
170 | | - if ( |
171 | | - multivariate_feature_state_values := feature_state.multivariate_feature_state_values |
172 | | - ): |
173 | | - feature_context["variants"] = [ |
174 | | - { |
175 | | - "value": multivariate_feature_state_value.multivariate_feature_option.value, |
176 | | - "weight": multivariate_feature_state_value.percentage_allocation, |
177 | | - } |
178 | | - for multivariate_feature_state_value in sorted( |
179 | | - multivariate_feature_state_values, |
180 | | - key=_get_multivariate_feature_state_value_id, |
181 | | - ) |
182 | | - ] |
183 | | - if feature_segment := feature_state.feature_segment: |
184 | | - if (priority := feature_segment.priority) is not None: |
185 | | - feature_context["priority"] = priority |
186 | | - features[feature_state.feature.name] = feature_context |
187 | | - return features |
188 | | - |
189 | | - |
190 | | -def _map_segment_rules_to_segment_context_rules( |
191 | | - rules: typing.List[SegmentRuleModel], |
192 | | -) -> typing.List[SegmentRule]: |
193 | | - """ |
194 | | - Map segment rules to segment rules for the evaluation context. |
195 | | -
|
196 | | - :param rules: A list of SegmentRuleModel objects. |
197 | | - :return: A list of SegmentRule objects. |
198 | | - """ |
199 | | - return [ |
200 | | - { |
201 | | - "type": rule.type, |
202 | | - "conditions": [ |
203 | | - { |
204 | | - "property": condition.property_ or "", |
205 | | - "operator": condition.operator, |
206 | | - "value": condition.value or "", |
207 | | - } |
208 | | - for condition in rule.conditions |
209 | | - ], |
210 | | - "rules": _map_segment_rules_to_segment_context_rules(rule.rules), |
211 | | - } |
212 | | - for rule in rules |
213 | | - ] |
214 | | - |
215 | | - |
216 | | -def map_flag_results_to_feature_states( |
217 | | - flag_results: typing.List[FlagResult], |
218 | | -) -> typing.List[FeatureStateModel]: |
| 8 | +def map_any_value_to_context_value(value: typing.Any) -> ContextValue: |
219 | 9 | """ |
220 | | - Map flag results to feature states. |
| 10 | + Try to coerce a value of arbitrary type to a context value type. |
| 11 | + Union member-specific constraints, such as max string value length, are ignored here. |
| 12 | + Replicate behaviour from marshmallow/pydantic V1 for number-like strings. |
| 13 | + For decimals return an int in case of unset exponent. |
| 14 | + When in doubt, return string. |
221 | 15 |
|
222 | | - :param flag_results: A list of FlagResult objects. |
223 | | - :return: A list of FeatureStateModel objects. |
| 16 | + Can be used as a `pydantic.BeforeValidator`. |
224 | 17 | """ |
225 | | - return [ |
226 | | - FeatureStateModel( |
227 | | - feature=FeatureModel( |
228 | | - id=flag_result["feature_key"], |
229 | | - name=flag_result["name"], |
230 | | - type=( |
231 | | - "MULTIVARIATE" |
232 | | - if flag_result.get("reason", "").startswith("SPLIT") |
233 | | - else "STANDARD" |
234 | | - ), |
235 | | - ), |
236 | | - enabled=flag_result["enabled"], |
237 | | - feature_state_value=flag_result["value"], |
238 | | - ) |
239 | | - for flag_result in flag_results |
240 | | - ] |
| 18 | + if is_context_value(value): |
| 19 | + if isinstance(value, str): |
| 20 | + return _map_string_value_to_context_value(value) |
| 21 | + return value |
| 22 | + if isinstance(value, Decimal): |
| 23 | + if value.as_tuple().exponent: |
| 24 | + return float(str(value)) |
| 25 | + return int(value) |
| 26 | + return str(value) |
241 | 27 |
|
242 | 28 |
|
243 | | -def _get_multivariate_feature_state_value_id( |
244 | | - multivariate_feature_state_value: MultivariateFeatureStateValueModel, |
245 | | -) -> int: |
246 | | - return ( |
247 | | - multivariate_feature_state_value.id |
248 | | - or multivariate_feature_state_value.mv_fs_value_uuid.int |
249 | | - ) |
| 29 | +_int_pattern = re.compile(r"-?[0-9]+") |
| 30 | +_float_pattern = re.compile(r"-?[0-9]+\.[0-9]+") |
250 | 31 |
|
251 | 32 |
|
252 | | -def _get_name(feature_state: FeatureStateModel) -> str: |
253 | | - return feature_state.feature.name |
| 33 | +def _map_string_value_to_context_value(value: str) -> ContextValue: |
| 34 | + if _int_pattern.fullmatch(value): |
| 35 | + return int(value) |
| 36 | + if _float_pattern.fullmatch(value): |
| 37 | + return float(value) |
| 38 | + return value |
0 commit comments