From 015b099b3ac4129483ad25cbacabb454de460a6b Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 4 Feb 2026 11:52:52 +0530 Subject: [PATCH 1/8] feat: vendor Pydantic models from flag-engine fix/missing-export branch Temporary vendoring of Pydantic models to maintain compatibility during migration to flag-engine v10. These models will be removed once the codebase is fully migrated to use the TypedDict-based evaluation API. Includes: - Identity, Trait, and Feature state models - Segment models with rule evaluation - Environment and Project models - Context mappers bridging Pydantic models to v10 TypedDicts --- api/util/engine_models/__init__.py | 8 + api/util/engine_models/context/__init__.py | 0 api/util/engine_models/context/mappers.py | 198 ++++++++++++++++++ .../engine_models/environments/__init__.py | 0 .../environments/integrations/__init__.py | 0 .../environments/integrations/models.py | 9 + api/util/engine_models/environments/models.py | 56 +++++ api/util/engine_models/features/__init__.py | 0 api/util/engine_models/features/models.py | 129 ++++++++++++ api/util/engine_models/identities/__init__.py | 0 api/util/engine_models/identities/models.py | 93 ++++++++ .../identities/traits/__init__.py | 0 .../identities/traits/constants.py | 1 + .../engine_models/identities/traits/models.py | 8 + .../engine_models/identities/traits/types.py | 62 ++++++ .../engine_models/organisations/__init__.py | 0 .../engine_models/organisations/models.py | 13 ++ api/util/engine_models/projects/__init__.py | 0 api/util/engine_models/projects/models.py | 16 ++ api/util/engine_models/segments/__init__.py | 0 api/util/engine_models/segments/models.py | 41 ++++ api/util/engine_models/utils/__init__.py | 0 api/util/engine_models/utils/datetime.py | 5 + api/util/engine_models/utils/exceptions.py | 10 + api/util/engine_models/utils/hashing.py | 31 +++ 25 files changed, 680 insertions(+) create mode 100644 api/util/engine_models/__init__.py create mode 100644 api/util/engine_models/context/__init__.py create mode 100644 api/util/engine_models/context/mappers.py create mode 100644 api/util/engine_models/environments/__init__.py create mode 100644 api/util/engine_models/environments/integrations/__init__.py create mode 100644 api/util/engine_models/environments/integrations/models.py create mode 100644 api/util/engine_models/environments/models.py create mode 100644 api/util/engine_models/features/__init__.py create mode 100644 api/util/engine_models/features/models.py create mode 100644 api/util/engine_models/identities/__init__.py create mode 100644 api/util/engine_models/identities/models.py create mode 100644 api/util/engine_models/identities/traits/__init__.py create mode 100644 api/util/engine_models/identities/traits/constants.py create mode 100644 api/util/engine_models/identities/traits/models.py create mode 100644 api/util/engine_models/identities/traits/types.py create mode 100644 api/util/engine_models/organisations/__init__.py create mode 100644 api/util/engine_models/organisations/models.py create mode 100644 api/util/engine_models/projects/__init__.py create mode 100644 api/util/engine_models/projects/models.py create mode 100644 api/util/engine_models/segments/__init__.py create mode 100644 api/util/engine_models/segments/models.py create mode 100644 api/util/engine_models/utils/__init__.py create mode 100644 api/util/engine_models/utils/datetime.py create mode 100644 api/util/engine_models/utils/exceptions.py create mode 100644 api/util/engine_models/utils/hashing.py diff --git a/api/util/engine_models/__init__.py b/api/util/engine_models/__init__.py new file mode 100644 index 000000000000..a1227dbe1141 --- /dev/null +++ b/api/util/engine_models/__init__.py @@ -0,0 +1,8 @@ +""" +Vendored Pydantic models from flagsmith-flag-engine's fix/missing-export branch. + +TEMPORARY: This module is a temporary measure to maintain compatibility during +the migration to flag-engine v10. These Pydantic models will be removed once +the codebase is fully migrated to use the TypedDict-based evaluation API +provided by flag-engine v10. +""" diff --git a/api/util/engine_models/context/__init__.py b/api/util/engine_models/context/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/context/mappers.py b/api/util/engine_models/context/mappers.py new file mode 100644 index 000000000000..fe1ca58638d0 --- /dev/null +++ b/api/util/engine_models/context/mappers.py @@ -0,0 +1,198 @@ +""" +Vendored and adapted mappers from flagsmith-flag-engine's fix/missing-export branch. + +The original `map_environment_identity_to_context` function has been adapted to +return v10's EvaluationContext TypedDict instead of the original return type. +""" + +import typing + +from flag_engine.context.types import ( + EvaluationContext, + FeatureContext, + SegmentContext, + SegmentRule, +) + +from util.engine_models.features.models import ( + FeatureStateModel, + MultivariateFeatureStateValueModel, +) +from util.engine_models.identities.models import IdentityModel +from util.engine_models.identities.traits.models import TraitModel +from util.engine_models.segments.models import SegmentModel, SegmentRuleModel + + +def map_environment_identity_to_context( + environment: typing.Any, # Accepts both Django Environment and Pydantic EnvironmentModel + identity: IdentityModel, + override_traits: typing.Optional[typing.List[TraitModel]], +) -> EvaluationContext: + """ + Map an environment and IdentityModel to an EvaluationContext. + + Vendored from flagsmith-flag-engine's fix/missing-export branch and adapted + to return v10's EvaluationContext TypedDict. + + This function uses duck typing - it only accesses `api_key` and `name` + attributes from the environment, which exist on both Django Environment + models and Pydantic EnvironmentModel objects. + + :param environment: The environment object (Django or Pydantic). + :param identity: The identity model object (Pydantic IdentityModel). + :param override_traits: A list of TraitModel objects, to be used in place of + `identity.identity_traits` if provided. + :return: An EvaluationContext containing the environment and identity. + """ + return { + "environment": { + "key": environment.api_key, + "name": environment.name or "", + }, + "identity": { + "identifier": identity.identifier, + "key": str(identity.django_id or identity.composite_key), + "traits": { + trait.trait_key: trait.trait_value + for trait in ( + override_traits + if override_traits is not None + else identity.identity_traits + ) + }, + }, + } + + +def _map_feature_states_to_feature_contexts( + feature_states: typing.List[FeatureStateModel], +) -> typing.Dict[str, FeatureContext]: + """ + Map feature states to feature contexts. + + :param feature_states: A list of FeatureStateModel objects. + :return: A dictionary mapping feature names to their contexts. + """ + features: typing.Dict[str, FeatureContext] = {} + for feature_state in feature_states: + feature_context: FeatureContext = { + "key": str(feature_state.django_id or feature_state.featurestate_uuid), + "name": feature_state.feature.name, + "enabled": feature_state.enabled, + "value": feature_state.feature_state_value, + } + multivariate_feature_state_values: typing.List[ + MultivariateFeatureStateValueModel + ] + if multivariate_feature_state_values := list( + feature_state.multivariate_feature_state_values + ): + sorted_mv_values = sorted( + multivariate_feature_state_values, + key=_get_multivariate_feature_state_value_id, + ) + feature_context["variants"] = [ + { + "value": mv_value.multivariate_feature_option.value, + "weight": mv_value.percentage_allocation, + "priority": idx, + } + for idx, mv_value in enumerate(sorted_mv_values) + ] + if feature_segment := feature_state.feature_segment: + if (priority := feature_segment.priority) is not None: + feature_context["priority"] = priority + features[feature_state.feature.name] = feature_context + return features + + +def _map_segment_rules_to_segment_context_rules( + rules: typing.List[SegmentRuleModel], +) -> typing.List[SegmentRule]: + """ + Map segment rules to segment rules for the evaluation context. + + :param rules: A list of SegmentRuleModel objects. + :return: A list of SegmentRule objects. + """ + return [ + { + "type": rule.type, + "conditions": [ + { + "property": condition.property_ or "", + "operator": condition.operator, + "value": condition.value or "", + } + for condition in rule.conditions + ], + "rules": _map_segment_rules_to_segment_context_rules(rule.rules), + } + for rule in rules + ] + + +def _get_multivariate_feature_state_value_id( + multivariate_feature_state_value: MultivariateFeatureStateValueModel, +) -> int: + return ( + multivariate_feature_state_value.id + or multivariate_feature_state_value.mv_fs_value_uuid.int + ) + + +def map_segment_to_segment_context(segment: SegmentModel) -> SegmentContext: + """ + Map a SegmentModel Pydantic model to a SegmentContext TypedDict. + + :param segment: The SegmentModel object. + :return: A SegmentContext TypedDict. + """ + segment_ctx: SegmentContext = { + "key": str(segment.id), + "name": segment.name, + "rules": _map_segment_rules_to_segment_context_rules(segment.rules), + } + if segment_feature_states := segment.feature_states: + segment_ctx["overrides"] = list( + _map_feature_states_to_feature_contexts(segment_feature_states).values() + ) + return segment_ctx + + +def is_context_in_segment( + context: EvaluationContext, + segment: SegmentModel, +) -> bool: + """ + Check if an evaluation context matches a segment. + + This is a compatibility wrapper that bridges the Pydantic SegmentModel + with the v10 flag-engine's TypedDict-based evaluation API. + + :param context: The EvaluationContext (TypedDict). + :param segment: The SegmentModel (Pydantic model). + :return: True if the context matches the segment rules. + """ + from flag_engine.segments.evaluator import ( + is_context_in_segment as v10_is_context_in_segment, + ) + + segment_context = map_segment_to_segment_context(segment) + return v10_is_context_in_segment(context, segment_context) + + +def get_context_segments( + context: EvaluationContext, + segments: typing.List[SegmentModel], +) -> typing.List[SegmentModel]: + """ + Get the list of segments that match a given evaluation context. + + This is a compatibility function for code that expects the old API. + + :param context: The EvaluationContext (TypedDict). + :param segments: List of SegmentModel objects to check. + :return: List of matching SegmentModel objects. + """ + return [segment for segment in segments if is_context_in_segment(context, segment)] diff --git a/api/util/engine_models/environments/__init__.py b/api/util/engine_models/environments/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/environments/integrations/__init__.py b/api/util/engine_models/environments/integrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/environments/integrations/models.py b/api/util/engine_models/environments/integrations/models.py new file mode 100644 index 000000000000..aa0b456b190c --- /dev/null +++ b/api/util/engine_models/environments/integrations/models.py @@ -0,0 +1,9 @@ +from typing import Optional + +from pydantic import BaseModel + + +class IntegrationModel(BaseModel): + api_key: Optional[str] = None + base_url: Optional[str] = None + entity_selector: Optional[str] = None diff --git a/api/util/engine_models/environments/models.py b/api/util/engine_models/environments/models.py new file mode 100644 index 000000000000..4a043587ecd3 --- /dev/null +++ b/api/util/engine_models/environments/models.py @@ -0,0 +1,56 @@ +import typing +from datetime import datetime + +from pydantic import BaseModel, Field + +from util.engine_models.environments.integrations.models import IntegrationModel +from util.engine_models.features.models import FeatureStateModel +from util.engine_models.identities.models import IdentityModel +from util.engine_models.projects.models import ProjectModel +from util.engine_models.utils.datetime import utcnow_with_tz + + +class EnvironmentAPIKeyModel(BaseModel): + id: int + key: str + created_at: datetime + name: str + client_api_key: str + expires_at: typing.Optional[datetime] = None + active: bool = True + + @property + def is_valid(self) -> bool: + return self.active and ( + not self.expires_at or self.expires_at > utcnow_with_tz() + ) + + +class WebhookModel(BaseModel): + url: str + secret: str + + +class EnvironmentModel(BaseModel): + id: int + api_key: str + project: ProjectModel + feature_states: typing.List[FeatureStateModel] = Field(default_factory=list) + identity_overrides: typing.List[IdentityModel] = Field(default_factory=list) + + name: typing.Optional[str] = None + allow_client_traits: bool = True + updated_at: datetime = Field(default_factory=utcnow_with_tz) + hide_sensitive_data: bool = False + hide_disabled_flags: typing.Optional[bool] = None + use_identity_composite_key_for_hashing: bool = False + use_identity_overrides_in_local_eval: bool = False + + amplitude_config: typing.Optional[IntegrationModel] = None + dynatrace_config: typing.Optional[IntegrationModel] = None + heap_config: typing.Optional[IntegrationModel] = None + mixpanel_config: typing.Optional[IntegrationModel] = None + rudderstack_config: typing.Optional[IntegrationModel] = None + segment_config: typing.Optional[IntegrationModel] = None + + webhook_config: typing.Optional[WebhookModel] = None diff --git a/api/util/engine_models/features/__init__.py b/api/util/engine_models/features/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/features/models.py b/api/util/engine_models/features/models.py new file mode 100644 index 000000000000..96c6bc1bed74 --- /dev/null +++ b/api/util/engine_models/features/models.py @@ -0,0 +1,129 @@ +import typing +import uuid + +from annotated_types import Ge, Le, SupportsLt +from pydantic import UUID4, BaseModel, Field, model_validator +from pydantic_collections import BaseCollectionModel # type: ignore[import-untyped] +from typing_extensions import Annotated + +from util.engine_models.utils.exceptions import InvalidPercentageAllocation +from util.engine_models.utils.hashing import get_hashed_percentage_for_object_ids + + +class FeatureModel(BaseModel): + id: int + name: str + type: str + + def __eq__(self, other: object) -> bool: + return isinstance(other, FeatureModel) and self.id == other.id + + def __hash__(self) -> int: + return hash(self.id) + + +class MultivariateFeatureOptionModel(BaseModel): + value: typing.Any + id: typing.Optional[int] = None + + +class MultivariateFeatureStateValueModel(BaseModel): + multivariate_feature_option: MultivariateFeatureOptionModel + percentage_allocation: Annotated[float, Ge(0), Le(100)] + id: typing.Optional[int] = None + mv_fs_value_uuid: UUID4 = Field(default_factory=uuid.uuid4) + + +class FeatureSegmentModel(BaseModel): + priority: typing.Optional[int] = None + + +class MultivariateFeatureStateValueList( + BaseCollectionModel[MultivariateFeatureStateValueModel] # type: ignore[misc] +): + @staticmethod + def _ensure_correct_percentage_allocations( + value: typing.List[MultivariateFeatureStateValueModel], + ) -> typing.List[MultivariateFeatureStateValueModel]: + if ( + sum( + multivariate_feature_state.percentage_allocation + for multivariate_feature_state in value + ) + > 100 + ): + raise InvalidPercentageAllocation( + "Total percentage allocation for feature must be less or equal to 100 percent" + ) + return value + + percentage_allocations_model_validator = model_validator(mode="after")( + _ensure_correct_percentage_allocations + ) + + def append( + self, + multivariate_feature_state_value: MultivariateFeatureStateValueModel, + ) -> None: + self._ensure_correct_percentage_allocations( + [*self, multivariate_feature_state_value], + ) + super().append(multivariate_feature_state_value) + + +class FeatureStateModel(BaseModel, validate_assignment=True): + feature: FeatureModel + enabled: bool + django_id: typing.Optional[int] = None + feature_segment: typing.Optional[FeatureSegmentModel] = None + featurestate_uuid: UUID4 = Field(default_factory=uuid.uuid4) + feature_state_value: typing.Any = None + multivariate_feature_state_values: MultivariateFeatureStateValueList = Field( + default_factory=MultivariateFeatureStateValueList + ) + + def set_value(self, value: typing.Any) -> None: + self.feature_state_value = value + + def get_value(self, identity_id: typing.Union[None, int, str] = None) -> typing.Any: + """ + Get the value of the feature state. + + :param identity_id: a unique identifier for the identity, can be either a + numeric id or a string but must be unique for the identity. + :return: the value of the feature state. + """ + if identity_id and len(self.multivariate_feature_state_values) > 0: + return self._get_multivariate_value(identity_id) + return self.feature_state_value + + def _get_multivariate_value( + self, identity_id: typing.Union[int, str] + ) -> typing.Any: + percentage_value = get_hashed_percentage_for_object_ids( + [self.django_id or str(self.featurestate_uuid), identity_id] + ) + + # Iterate over the mv options in order of id (so we get the same value each + # time) to determine the correct value to return to the identity based on + # the percentage allocations of the multivariate options. This gives us a + # way to ensure that the same value is returned every time we use the same + # percentage value. + start_percentage = 0.0 + + def _mv_fs_sort_key(mv_value: MultivariateFeatureStateValueModel) -> SupportsLt: + return mv_value.id or mv_value.mv_fs_value_uuid + + for mv_value in sorted( + self.multivariate_feature_state_values, + key=_mv_fs_sort_key, + ): + limit = mv_value.percentage_allocation + start_percentage + if start_percentage <= percentage_value < limit: + return mv_value.multivariate_feature_option.value + + start_percentage = limit + + # default to return the control value if no MV values found, although this + # should never happen + return self.feature_state_value diff --git a/api/util/engine_models/identities/__init__.py b/api/util/engine_models/identities/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/identities/models.py b/api/util/engine_models/identities/models.py new file mode 100644 index 000000000000..ce7173c08309 --- /dev/null +++ b/api/util/engine_models/identities/models.py @@ -0,0 +1,93 @@ +import datetime +import typing +import uuid + +from pydantic import UUID4, BaseModel, Field, computed_field, model_validator +from pydantic_collections import BaseCollectionModel # type: ignore[import-untyped] + +from util.engine_models.features.models import FeatureStateModel +from util.engine_models.identities.traits.models import TraitModel +from util.engine_models.utils.datetime import utcnow_with_tz +from util.engine_models.utils.exceptions import DuplicateFeatureState + + +class IdentityFeaturesList(BaseCollectionModel[FeatureStateModel]): # type: ignore[misc] + @staticmethod + def _ensure_unique_feature_ids( + value: typing.Sequence[FeatureStateModel], + ) -> None: + for i, feature_state in enumerate(value, start=1): + if feature_state.feature.id in [ + feature_state.feature.id for feature_state in value[i:] + ]: + raise DuplicateFeatureState( + f"Feature state for feature id={feature_state.feature.id} already exists" + ) + + @model_validator(mode="after") + def ensure_unique_feature_ids(self) -> "IdentityFeaturesList": + self._ensure_unique_feature_ids(self.root) + return self + + def append(self, feature_state: "FeatureStateModel") -> None: + self._ensure_unique_feature_ids([*self, feature_state]) + super().append(feature_state) + + +class IdentityModel(BaseModel): + identifier: str + environment_api_key: str + created_date: datetime.datetime = Field(default_factory=utcnow_with_tz) + identity_features: IdentityFeaturesList = Field( + default_factory=IdentityFeaturesList + ) + identity_traits: typing.List[TraitModel] = Field(default_factory=list) + identity_uuid: UUID4 = Field(default_factory=uuid.uuid4) + django_id: typing.Optional[int] = None + + dashboard_alias: typing.Optional[str] = None + + @computed_field # type: ignore[prop-decorator] + @property + def composite_key(self) -> str: + return self.generate_composite_key(self.environment_api_key, self.identifier) + + @staticmethod + def generate_composite_key(env_key: str, identifier: str) -> str: + return f"{env_key}_{identifier}" + + def get_hash_key(self, use_identity_composite_key_for_hashing: bool) -> str: + return ( + self.composite_key + if use_identity_composite_key_for_hashing + else self.identifier + ) + + def update_traits( + self, traits: typing.List[TraitModel] + ) -> typing.Tuple[typing.List[TraitModel], bool]: + existing_traits = {trait.trait_key: trait for trait in self.identity_traits} + traits_changed = False + + for trait in traits: + existing_trait = existing_traits.get(trait.trait_key) + + if trait.trait_value is None and existing_trait: + existing_traits.pop(trait.trait_key) + traits_changed = True + + elif getattr(existing_trait, "trait_value", None) != trait.trait_value: + existing_traits[trait.trait_key] = trait + traits_changed = True + + self.identity_traits = list(existing_traits.values()) + return self.identity_traits, traits_changed + + def prune_features(self, valid_feature_names: typing.List[str]) -> None: + self.identity_features = IdentityFeaturesList( + [ + fs + for fs in self.identity_features + if fs.feature.name in valid_feature_names + ] + ) diff --git a/api/util/engine_models/identities/traits/__init__.py b/api/util/engine_models/identities/traits/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/identities/traits/constants.py b/api/util/engine_models/identities/traits/constants.py new file mode 100644 index 000000000000..359ecbd4978d --- /dev/null +++ b/api/util/engine_models/identities/traits/constants.py @@ -0,0 +1 @@ +TRAIT_STRING_VALUE_MAX_LENGTH: int = 2000 diff --git a/api/util/engine_models/identities/traits/models.py b/api/util/engine_models/identities/traits/models.py new file mode 100644 index 000000000000..25069d7da767 --- /dev/null +++ b/api/util/engine_models/identities/traits/models.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field + +from util.engine_models.identities.traits.types import ContextValue + + +class TraitModel(BaseModel): + trait_key: str + trait_value: ContextValue = Field(...) diff --git a/api/util/engine_models/identities/traits/types.py b/api/util/engine_models/identities/traits/types.py new file mode 100644 index 000000000000..1b9c55f6d99e --- /dev/null +++ b/api/util/engine_models/identities/traits/types.py @@ -0,0 +1,62 @@ +import re +from decimal import Decimal +from typing import Any, Union, get_args + +from pydantic import BeforeValidator +from pydantic.types import AllowInfNan, StrictBool, StringConstraints +from typing_extensions import Annotated, TypeGuard + +from util.engine_models.identities.traits.constants import TRAIT_STRING_VALUE_MAX_LENGTH + +_UnconstrainedContextValue = Union[None, int, float, bool, str] + + +def map_any_value_to_trait_value(value: Any) -> _UnconstrainedContextValue: + """ + Try to coerce a value of arbitrary type to a trait value type. + Union member-specific constraints, such as max string value length, are ignored here. + Replicate behaviour from marshmallow/pydantic V1 for number-like strings. + For decimals return an int in case of unset exponent. + When in doubt, return string. + + Supposed to be used as a `pydantic.BeforeValidator`. + """ + if _is_trait_value(value): + if isinstance(value, str): + return _map_string_value_to_trait_value(value) + return value + if isinstance(value, Decimal): + if value.as_tuple().exponent: + return float(str(value)) + return int(value) + return str(value) + + +_int_pattern = re.compile(r"-?[0-9]+") +_float_pattern = re.compile(r"-?[0-9]+\.[0-9]+") + + +def _map_string_value_to_trait_value(value: str) -> _UnconstrainedContextValue: + if _int_pattern.fullmatch(value): + return int(value) + if _float_pattern.fullmatch(value): + return float(value) + return value + + +def _is_trait_value(value: Any) -> TypeGuard[_UnconstrainedContextValue]: + return isinstance(value, get_args(_UnconstrainedContextValue)) + + +ContextValue = Annotated[ + Union[ + None, + StrictBool, + Annotated[float, AllowInfNan(False)], + int, + Annotated[str, StringConstraints(max_length=TRAIT_STRING_VALUE_MAX_LENGTH)], + ], + BeforeValidator(map_any_value_to_trait_value), +] + +TraitValue = ContextValue diff --git a/api/util/engine_models/organisations/__init__.py b/api/util/engine_models/organisations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/organisations/models.py b/api/util/engine_models/organisations/models.py new file mode 100644 index 000000000000..6599a1a78f7d --- /dev/null +++ b/api/util/engine_models/organisations/models.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class OrganisationModel(BaseModel): + id: int + name: str + feature_analytics: bool = False + stop_serving_flags: bool = False + persist_trait_data: bool = True + + @property + def unique_slug(self) -> str: + return str(self.id) + "-" + self.name diff --git a/api/util/engine_models/projects/__init__.py b/api/util/engine_models/projects/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/projects/models.py b/api/util/engine_models/projects/models.py new file mode 100644 index 000000000000..9a5ebd0ae9ee --- /dev/null +++ b/api/util/engine_models/projects/models.py @@ -0,0 +1,16 @@ +import typing + +from pydantic import BaseModel, Field + +from util.engine_models.organisations.models import OrganisationModel +from util.engine_models.segments.models import SegmentModel + + +class ProjectModel(BaseModel): + id: int + name: str + organisation: OrganisationModel + hide_disabled_flags: bool = False + segments: typing.List[SegmentModel] = Field(default_factory=list) + enable_realtime_updates: bool = False + server_key_only_feature_ids: typing.List[int] = Field(default_factory=list) diff --git a/api/util/engine_models/segments/__init__.py b/api/util/engine_models/segments/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/segments/models.py b/api/util/engine_models/segments/models.py new file mode 100644 index 000000000000..9e2f8d87ff05 --- /dev/null +++ b/api/util/engine_models/segments/models.py @@ -0,0 +1,41 @@ +import typing + +from flag_engine.segments import constants +from flag_engine.segments.types import ConditionOperator, RuleType +from pydantic import BaseModel, BeforeValidator, Field +from typing_extensions import Annotated + +from util.engine_models.features.models import FeatureStateModel + +LaxStr = Annotated[str, BeforeValidator(lambda x: str(x))] + + +class SegmentConditionModel(BaseModel): + operator: ConditionOperator + value: typing.Optional[LaxStr] = None + property_: typing.Optional[str] = None + + +class SegmentRuleModel(BaseModel): + type: RuleType + rules: typing.List["SegmentRuleModel"] = Field(default_factory=list) + conditions: typing.List[SegmentConditionModel] = Field(default_factory=list) + + @staticmethod + def none(iterable: typing.Iterable[object]) -> bool: + return not any(iterable) + + @property + def matching_function(self) -> typing.Callable[[typing.Iterable[object]], bool]: + return { + constants.ANY_RULE: any, + constants.ALL_RULE: all, + constants.NONE_RULE: SegmentRuleModel.none, + }[self.type] + + +class SegmentModel(BaseModel): + id: int + name: str + rules: typing.List[SegmentRuleModel] = Field(default_factory=list) + feature_states: typing.List[FeatureStateModel] = Field(default_factory=list) diff --git a/api/util/engine_models/utils/__init__.py b/api/util/engine_models/utils/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/util/engine_models/utils/datetime.py b/api/util/engine_models/utils/datetime.py new file mode 100644 index 000000000000..78c8697d7da1 --- /dev/null +++ b/api/util/engine_models/utils/datetime.py @@ -0,0 +1,5 @@ +from datetime import datetime, timezone + + +def utcnow_with_tz() -> datetime: + return datetime.now(tz=timezone.utc) diff --git a/api/util/engine_models/utils/exceptions.py b/api/util/engine_models/utils/exceptions.py new file mode 100644 index 000000000000..98821885c7c4 --- /dev/null +++ b/api/util/engine_models/utils/exceptions.py @@ -0,0 +1,10 @@ +class FeatureStateNotFound(Exception): + pass + + +class DuplicateFeatureState(ValueError): + pass + + +class InvalidPercentageAllocation(ValueError): + pass diff --git a/api/util/engine_models/utils/hashing.py b/api/util/engine_models/utils/hashing.py new file mode 100644 index 000000000000..e8bc991f5452 --- /dev/null +++ b/api/util/engine_models/utils/hashing.py @@ -0,0 +1,31 @@ +import hashlib +import typing + + +def get_hashed_percentage_for_object_ids( + object_ids: typing.Iterable[typing.Union[int, str]], iterations: int = 1 +) -> float: + """ + Given a list of object ids, get a floating point number between 0 (inclusive) and + 100 (exclusive) based on the hash of those ids. This should give the same value + every time for any list of ids. + + :param object_ids: list of object ids to calculate the hash for + :param iterations: num times to include each id in the generated string to hash + :return: (float) number between 0 (inclusive) and 100 (exclusive) + """ + + to_hash = ",".join(str(id_) for id_ in list(object_ids) * iterations) + hashed_value = hashlib.md5(to_hash.encode("utf-8")) + hashed_value_as_int = int(hashed_value.hexdigest(), base=16) + value = ((hashed_value_as_int % 9999) / 9998) * 100 + + if value == 100: + # since we want a number between 0 (inclusive) and 100 (exclusive), in the + # unlikely case that we get the exact number 100, we call the method again + # and increase the number of iterations to ensure we get a different result + return get_hashed_percentage_for_object_ids( + object_ids=object_ids, iterations=iterations + 1 + ) + + return value From ada8181f9fa4604ea9c0001045eba014e05150e8 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 4 Feb 2026 15:40:17 +0530 Subject: [PATCH 2/8] feat: upgrade flagsmith-flag-engine to v10 and migrate to vendored models - Bump flagsmith-flag-engine from fix/missing-export branch to ^10.0.3 - Bump flagsmith SDK from ^3.10.0 to ^5.1.1 - Add pydantic-collections dependency Update all imports to use vendored Pydantic models from util/engine_models instead of flag_engine (which removed Pydantic models in v7+). Clean up unused code from vendored models: - Remove EnvironmentAPIKeyModel.is_valid property - Remove FeatureModel.__eq__ and __hash__ methods - Remove OrganisationModel.unique_slug property - Remove SegmentRuleModel.none() and matching_function Add pragma: no cover for unreachable edge cases: - FeatureStateModel._get_multivariate_value fallback return - get_hashed_percentage_for_object_ids hash collision case Add tests for full coverage: - test_get_segment_ids_with_segment_feature_overrides - test_map_any_value_to_trait_value --- api/app/pagination.py | 3 +- api/e2etests/e2e_seed_data.py | 2 +- api/edge_api/identities/export.py | 2 +- api/edge_api/identities/models.py | 4 +- api/edge_api/identities/serializers.py | 22 +- api/edge_api/identities/utils.py | 2 +- api/edge_api/identities/views.py | 4 +- api/environments/dynamodb/services.py | 3 +- api/environments/dynamodb/types.py | 3 +- .../dynamodb/wrappers/identity_wrapper.py | 10 +- api/environments/identities/models.py | 6 +- api/environments/identities/serializers.py | 2 +- api/integrations/webhook/serializers.py | 2 +- api/organisations/models.py | 2 +- api/poetry.lock | 242 +++++++++++++++--- api/pyproject.toml | 5 +- .../edge_api/identities/conftest.py | 2 +- ...est_edge_identity_featurestates_viewset.py | 14 +- .../test_edge_api_identities_serializers.py | 2 +- .../identities/test_edge_identity_models.py | 2 +- .../test_unit_dynamodb_identity_wrapper.py | 77 +++++- .../traits/test_unit_traits_types.py | 29 +++ .../util/mappers/test_unit_mappers_engine.py | 42 +-- api/util/engine_models/environments/models.py | 6 - api/util/engine_models/features/models.py | 8 +- .../engine_models/organisations/models.py | 4 - api/util/engine_models/segments/models.py | 13 - api/util/engine_models/utils/hashing.py | 2 +- api/util/mappers/dynamodb.py | 5 +- api/util/mappers/engine.py | 24 +- 30 files changed, 401 insertions(+), 143 deletions(-) create mode 100644 api/tests/unit/util/engine_models/identities/traits/test_unit_traits_types.py diff --git a/api/app/pagination.py b/api/app/pagination.py index da7bb63c767b..3a31437c2906 100644 --- a/api/app/pagination.py +++ b/api/app/pagination.py @@ -3,10 +3,11 @@ from collections import OrderedDict from typing import Any -from flag_engine.identities.models import IdentityModel from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response +from util.engine_models.identities.models import IdentityModel + class CustomPagination(PageNumberPagination): page_size = 999 diff --git a/api/e2etests/e2e_seed_data.py b/api/e2etests/e2e_seed_data.py index b7dffa51bca8..3a08ea5fc1ef 100644 --- a/api/e2etests/e2e_seed_data.py +++ b/api/e2etests/e2e_seed_data.py @@ -10,7 +10,6 @@ VIEW_PROJECT, ) from django.conf import settings -from flag_engine.identities.models import IdentityModel as EngineIdentity from edge_api.identities.models import EdgeIdentity from environments.identities.models import Identity @@ -25,6 +24,7 @@ from organisations.subscriptions.constants import ENTERPRISE from projects.models import Project, UserProjectPermission from users.models import FFAdminUser, UserPermissionGroup +from util.engine_models.identities.models import IdentityModel as EngineIdentity # Password used by all the test users PASSWORD = "Str0ngp4ssw0rd!" diff --git a/api/edge_api/identities/export.py b/api/edge_api/identities/export.py index ba8f3d9bfd71..134b3fc96862 100644 --- a/api/edge_api/identities/export.py +++ b/api/edge_api/identities/export.py @@ -4,12 +4,12 @@ from decimal import Decimal from django.utils import timezone -from flag_engine.identities.traits.types import map_any_value_to_trait_value from edge_api.identities.models import EdgeIdentity from environments.identities.traits.models import Trait from features.models import Feature, FeatureState from features.multivariate.models import MultivariateFeatureOption +from util.engine_models.identities.traits.types import map_any_value_to_trait_value EXPORT_EDGE_IDENTITY_PAGINATION_LIMIT = 20000 diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index d1107d777664..718bf78bde55 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -3,8 +3,6 @@ from contextlib import suppress from django.db.models import Prefetch, Q -from flag_engine.features.models import FeatureStateModel -from flag_engine.identities.models import IdentityFeaturesList, IdentityModel from api_keys.user import APIKeyUser from edge_api.identities.tasks import ( @@ -20,6 +18,8 @@ from features.multivariate.models import MultivariateFeatureStateValue from features.versioning.versioning_service import get_environment_flags_dict from users.models import FFAdminUser +from util.engine_models.features.models import FeatureStateModel +from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel from util.mappers import map_engine_identity_to_identity_document diff --git a/api/edge_api/identities/serializers.py b/api/edge_api/identities/serializers.py index cfe36f620feb..099e2789531b 100644 --- a/api/edge_api/identities/serializers.py +++ b/api/edge_api/identities/serializers.py @@ -3,16 +3,6 @@ from django.utils import timezone from drf_spectacular.utils import extend_schema_field -from flag_engine.features.models import FeatureModel as EngineFeatureModel -from flag_engine.features.models import FeatureStateModel as EngineFeatureStateModel -from flag_engine.features.models import ( - MultivariateFeatureOptionModel as EngineMultivariateFeatureOptionModel, -) -from flag_engine.features.models import ( - MultivariateFeatureStateValueModel as EngineMultivariateFeatureStateValueModel, -) -from flag_engine.identities.models import IdentityModel as EngineIdentity -from flag_engine.utils.exceptions import DuplicateFeatureState from pydantic import ValidationError as PydanticValidationError from pyngo import drf_error_details from rest_framework import serializers @@ -25,6 +15,18 @@ from features.serializers import ( # type: ignore[attr-defined] FeatureStateValueSerializer, ) +from util.engine_models.features.models import FeatureModel as EngineFeatureModel +from util.engine_models.features.models import ( + FeatureStateModel as EngineFeatureStateModel, +) +from util.engine_models.features.models import ( + MultivariateFeatureOptionModel as EngineMultivariateFeatureOptionModel, +) +from util.engine_models.features.models import ( + MultivariateFeatureStateValueModel as EngineMultivariateFeatureStateValueModel, +) +from util.engine_models.identities.models import IdentityModel as EngineIdentity +from util.engine_models.utils.exceptions import DuplicateFeatureState from util.mappers import ( map_engine_identity_to_identity_document, map_feature_to_engine, diff --git a/api/edge_api/identities/utils.py b/api/edge_api/identities/utils.py index 768553377390..2345dc05ed0e 100644 --- a/api/edge_api/identities/utils.py +++ b/api/edge_api/identities/utils.py @@ -1,6 +1,6 @@ import typing -from flag_engine.features.models import FeatureStateModel +from util.engine_models.features.models import FeatureStateModel if typing.TYPE_CHECKING: from edge_api.identities.types import ChangeType, FeatureStateChangeDetails diff --git a/api/edge_api/identities/views.py b/api/edge_api/identities/views.py index 9538029f0917..ab241b6ba652 100644 --- a/api/edge_api/identities/views.py +++ b/api/edge_api/identities/views.py @@ -9,8 +9,6 @@ ) from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema -from flag_engine.identities.models import IdentityFeaturesList, IdentityModel -from flag_engine.identities.traits.models import TraitModel from pyngo import drf_error_details from rest_framework import status, viewsets from rest_framework.decorators import action, api_view, permission_classes @@ -56,6 +54,8 @@ from features.models import FeatureState from features.permissions import IdentityFeatureStatePermissions from projects.exceptions import DynamoNotEnabledError +from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel +from util.engine_models.identities.traits.models import TraitModel from . import edge_identity_service from .exceptions import TraitPersistenceError diff --git a/api/environments/dynamodb/services.py b/api/environments/dynamodb/services.py index 77ecab9c40bd..5f613e8290a8 100644 --- a/api/environments/dynamodb/services.py +++ b/api/environments/dynamodb/services.py @@ -2,8 +2,6 @@ from decimal import Decimal from typing import Generator, Iterable -from flag_engine.identities.models import IdentityModel - from environments.dynamodb import ( CapacityBudgetExceeded, DynamoEnvironmentV2Wrapper, @@ -16,6 +14,7 @@ ) from environments.models import Environment from projects.models import EdgeV2MigrationStatus +from util.engine_models.identities.models import IdentityModel from util.mappers import map_engine_feature_state_to_identity_override logger = logging.getLogger(__name__) diff --git a/api/environments/dynamodb/types.py b/api/environments/dynamodb/types.py index 30b9f6b8fe6c..688afdac4ad5 100644 --- a/api/environments/dynamodb/types.py +++ b/api/environments/dynamodb/types.py @@ -5,9 +5,10 @@ import boto3 from django.conf import settings -from flag_engine.features.models import FeatureStateModel from pydantic import BaseModel +from util.engine_models.features.models import FeatureStateModel + if typing.TYPE_CHECKING: from projects.models import EdgeV2MigrationStatus diff --git a/api/environments/dynamodb/wrappers/identity_wrapper.py b/api/environments/dynamodb/wrappers/identity_wrapper.py index a2b22c80280b..b9fa53957143 100644 --- a/api/environments/dynamodb/wrappers/identity_wrapper.py +++ b/api/environments/dynamodb/wrappers/identity_wrapper.py @@ -7,15 +7,17 @@ from boto3.dynamodb.conditions import Attr, Key from django.conf import settings from django.core.exceptions import ObjectDoesNotExist -from flag_engine.context.mappers import map_environment_identity_to_context -from flag_engine.environments.models import EnvironmentModel -from flag_engine.identities.models import IdentityModel -from flag_engine.segments.evaluator import get_context_segments from rest_framework.exceptions import NotFound from edge_api.identities.search import EdgeIdentitySearchData from environments.dynamodb.constants import IDENTITIES_PAGINATION_LIMIT from environments.dynamodb.wrappers.exceptions import CapacityBudgetExceeded +from util.engine_models.context.mappers import ( + get_context_segments, + map_environment_identity_to_context, +) +from util.engine_models.environments.models import EnvironmentModel +from util.engine_models.identities.models import IdentityModel from util.mappers import map_identity_to_identity_document from .base import BaseDynamoWrapper diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 73b374f619ac..3ec999698f8f 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -2,8 +2,6 @@ from django.db import models from django.db.models import Prefetch, Q -from flag_engine.context.mappers import map_environment_identity_to_context -from flag_engine.segments.evaluator import is_context_in_segment from environments.identities.managers import IdentityManager from environments.identities.traits.models import Trait @@ -13,6 +11,10 @@ from features.multivariate.models import MultivariateFeatureStateValue from features.versioning.versioning_service import get_environment_flags_list from segments.models import Segment +from util.engine_models.context.mappers import ( + is_context_in_segment, + map_environment_identity_to_context, +) from util.mappers.engine import ( map_identity_to_engine, map_segment_to_engine, diff --git a/api/environments/identities/serializers.py b/api/environments/identities/serializers.py index 1562d7051479..f3e47f42824f 100644 --- a/api/environments/identities/serializers.py +++ b/api/environments/identities/serializers.py @@ -1,7 +1,6 @@ import typing from drf_spectacular.utils import extend_schema_field -from flag_engine.features.models import FeatureStateModel from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -10,6 +9,7 @@ from environments.serializers import EnvironmentSerializerFull from features.models import FeatureState from features.serializers import FeatureStateSerializerFull +from util.engine_models.features.models import FeatureStateModel class IdentifierOnlyIdentitySerializer(serializers.ModelSerializer): # type: ignore[type-arg] diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index 2f07dbf992ab..9710541d3a2e 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -1,7 +1,6 @@ import typing from django.db.models import Q -from flag_engine.segments.evaluator import is_context_in_segment from rest_framework import serializers from features.serializers import FeatureStateSerializerFull @@ -9,6 +8,7 @@ BaseEnvironmentIntegrationModelSerializer, ) from segments.models import Segment +from util.engine_models.context.mappers import is_context_in_segment from util.mappers.engine import ( map_engine_identity_to_context, map_identity_to_engine, diff --git a/api/organisations/models.py b/api/organisations/models.py index 039de965e609..77f6909f2e0e 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -14,7 +14,6 @@ LifecycleModelMixin, hook, ) -from flag_engine.identities.traits.types import TraitValue from simple_history.models import HistoricalRecords # type: ignore[import-untyped] from core.models import SoftDeleteExportableModel @@ -53,6 +52,7 @@ ) from organisations.subscriptions.metadata import BaseSubscriptionMetadata from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata +from util.engine_models.identities.traits.types import TraitValue from webhooks.models import AbstractBaseExportableWebhookModel environment_cache = caches[settings.ENVIRONMENT_CACHE_NAME] diff --git a/api/poetry.lock b/api/poetry.lock index c3e9df2a0ec0..e364a2893e10 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -6,7 +6,7 @@ version = "0.6.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "workflows"] +groups = ["main", "dev"] files = [ {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, @@ -925,7 +925,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -1960,22 +1960,22 @@ files = [ [[package]] name = "flagsmith" -version = "3.10.0" +version = "5.1.1" description = "Flagsmith Python SDK" optional = false -python-versions = "<4,>=3.8.1" +python-versions = "<4,>=3.9" groups = ["main"] files = [ - {file = "flagsmith-3.10.0-py3-none-any.whl", hash = "sha256:e4be6921b5916663575951cf379edfc92765063e27e63f51486b3cf0bb8317c8"}, - {file = "flagsmith-3.10.0.tar.gz", hash = "sha256:ca82f7b29bc50299bb3099f49428c7376b140083577574d0b273c2936d8c9c06"}, + {file = "flagsmith-5.1.1-py3-none-any.whl", hash = "sha256:220943ff42bb631d4fad0f7d9ce89f56738d480657ce4aeb4b2cc779dcaea66a"}, + {file = "flagsmith-5.1.1.tar.gz", hash = "sha256:42fc5ad3eaa578777e4f6b955a44349e974f6d79ee004b220847a9b0ca778d91"}, ] [package.dependencies] -flagsmith-flag-engine = ">=6.0.2,<7.0.0" -pydantic = ">=2,<3" +flagsmith-flag-engine = ">=10.0.3,<11.0.0" requests = ">=2.32.3,<3.0.0" requests-futures = ">=1.0.1,<2.0.0" sseclient-py = ">=1.8.0,<2.0.0" +typing-extensions = ">=4.15.0,<5.0.0" [[package]] name = "flagsmith-auth-controller" @@ -2035,24 +2035,20 @@ test-tools = ["pyfakefs (>=5,<6)", "pytest-django (>=4,<5)"] [[package]] name = "flagsmith-flag-engine" -version = "6.0.2" +version = "10.0.3" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main", "workflows"] -files = [] -develop = false +files = [ + {file = "flagsmith_flag_engine-10.0.3-py3-none-any.whl", hash = "sha256:aed9009377fc1a6322483277f971f06d542668a69d93cbe4a3efd4baae78dfc1"}, + {file = "flagsmith_flag_engine-10.0.3.tar.gz", hash = "sha256:0aa449bb87bee54fc67b5c7ca25eca78246a7bbb5a6cc229260c3f262d58ac54"}, +] [package.dependencies] -pydantic = ">=2.3.0,<3" -pydantic-collections = ">=0.5.1,<1" -semver = ">=3.0.1" - -[package.source] -type = "git" -url = "https://github.com/Flagsmith/flagsmith-engine" -reference = "fix/missing-export" -resolved_reference = "f3780d62117211990f51529e58a380b9894f24fb" +jsonpath-rfc9535 = ">=0.1.5,<1" +semver = ">=3.0.4,<4" +typing-extensions = ">=4.14.1,<5" [[package]] name = "flagsmith-ldap" @@ -2686,6 +2682,31 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "iregexp-check" +version = "0.1.4" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main", "workflows"] +files = [ + {file = "iregexp_check-0.1.4-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:385a90d450706b9f934b5c82137247e24423c990d250da55630a792ccb7e2974"}, + {file = "iregexp_check-0.1.4-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:003434c2d7e13ea91e2ff1d5038f87641e9dc44513a0544e3c29e91dfb21b871"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9cbb6fe0aaae0c7b9b8d4ba05a6d8283cf747dbd06b8e0442f05e87c9d5e1c"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef58d44e4ae9aaca89be2898e416e6e168aff62cd5b1820d531fa855ee8e2fb1"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a473fb428b55031f64db1e52447d5bffd6bba2f3b760052592a44951cbddd8ab"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ce621946fc42e0d9f9475bf1360d91e281f84c199cbac2de24973e55bcdc92"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c670722da7283ed15d1401eca684628248491bb612e54a41bc60f86d32b67a5"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8e8bb2dc1f08110dde37ae52a42f2487365178f43625995579d6cca4ec9f683"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7beffdc3179334e18975a64399915922842880b8960dc4b04903f9b1ffdad35a"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:01b374e01719d9e2a1ad141aed5e5d34acf71e156db269b578a46570d32708af"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5a7b1340c34cc8c93b80716b75f7faaec3a8662631b1c33a249d68e78d8fdab2"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2dc5a74d3190e0ecd7e30a5394ed6086962ebe644f21d440954ec2d11de691f0"}, + {file = "iregexp_check-0.1.4-cp38-abi3-win32.whl", hash = "sha256:b24ef4264546a899e1e3407d111024d02af42f7b8575250dc4d9fc79011e2a5c"}, + {file = "iregexp_check-0.1.4-cp38-abi3-win_amd64.whl", hash = "sha256:50837bbe9b09abdb7b387d9c7dc2eda470a77e8b29ac315a0e1409b147db14bd"}, + {file = "iregexp_check-0.1.4.tar.gz", hash = "sha256:a98e77dd2d9fc91db04f8d9f295f3d69e402813bac5413f22e5866958a902bc1"}, +] + [[package]] name = "isort" version = "5.12.0" @@ -2746,6 +2767,22 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "jsonpath-rfc9535" +version = "0.2.0" +description = "RFC 9535 - JSONPath: Query Expressions for JSON in Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "workflows"] +files = [ + {file = "jsonpath_rfc9535-0.2.0-py3-none-any.whl", hash = "sha256:76488ac205e13af28dc1f8fccdd4df641a950605faad6c5b6b2451483a5b4624"}, + {file = "jsonpath_rfc9535-0.2.0.tar.gz", hash = "sha256:e02bbafede3457fe9313a8b7500c26043b87e61587d4b468ecdf95c51debbab4"}, +] + +[package.dependencies] +iregexp-check = ">=0.1.4" +regex = "*" + [[package]] name = "jsonschema" version = "4.25.1" @@ -3456,7 +3493,7 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "workflows"] +groups = ["main", "dev"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -3475,14 +3512,14 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-collections" -version = "0.5.4" +version = "0.6.0" description = "Collections of pydantic models" optional = false python-versions = "*" -groups = ["main", "workflows"] +groups = ["main"] files = [ - {file = "pydantic-collections-0.5.4.tar.gz", hash = "sha256:5bce65519456b4829f918c2456d58aac3620a866603461a702aafffe08845966"}, - {file = "pydantic_collections-0.5.4-py3-none-any.whl", hash = "sha256:5d107170c89fb17de229f5e8c4b4355af27594444fd0f93086048ccafa69238b"}, + {file = "pydantic_collections-0.6.0-py3-none-any.whl", hash = "sha256:ec559722abf6a0f80e6f00b3d28f0f39c0ed5feb1641166230eb75e9da880162"}, + {file = "pydantic_collections-0.6.0.tar.gz", hash = "sha256:c34d3fd1df5600b315cdecdd8e74eacd4c8c607b7e3f2c9392b2a15850a4ef9e"}, ] [package.dependencies] @@ -3495,7 +3532,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "workflows"] +groups = ["main", "dev"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -4243,6 +4280,147 @@ attrs = ">=22.2.0" rpds-py = ">=0.7.0" typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} +[[package]] +name = "regex" +version = "2026.1.15" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["main", "workflows"] +files = [ + {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"}, + {file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"}, + {file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"}, + {file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"}, + {file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"}, + {file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"}, + {file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"}, + {file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"}, + {file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"}, + {file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"}, + {file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"}, + {file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"}, + {file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"}, + {file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"}, + {file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"}, + {file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"}, + {file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"}, + {file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"}, + {file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"}, + {file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"}, + {file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"}, + {file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"}, + {file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"}, + {file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"}, + {file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"}, + {file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"}, +] + [[package]] name = "requests" version = "2.32.4" @@ -4575,14 +4753,14 @@ test = ["flake8 (==3.7.9)", "mock (==2.0.0)", "pylint (==2.8.0)"] [[package]] name = "semver" -version = "3.0.2" +version = "3.0.4" description = "Python helper for Semantic Versioning (https://semver.org)" optional = false python-versions = ">=3.7" groups = ["main", "workflows"] files = [ - {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, - {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] @@ -5119,7 +5297,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "workflows"] +groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -5399,4 +5577,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "624fae53a86a490cc3a58e59a1f3840251a47278613cf8fad7e30a31b525fc4d" +content-hash = "b5f32982c0744d48d4f1fbbb4e340f3787a0a5229f0f32e627047bf30547d3f0" diff --git a/api/pyproject.toml b/api/pyproject.toml index bf2dbcb69db5..28d84bb8989f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -135,7 +135,7 @@ environs = "^14.1.1" django-lifecycle = "~1.2.4" drf-writable-nested = "~0.6.2" django-filter = "~2.4.0" -flagsmith-flag-engine = { git = "https://github.com/Flagsmith/flagsmith-engine", branch = "fix/missing-export" } +flagsmith-flag-engine = "^10.0.3" boto3 = "~1.35.95" slack-sdk = "~3.9.0" asgiref = "~3.8.1" @@ -152,8 +152,9 @@ django-ordered-model = "~3.4.1" django-ses = "~3.5.0" django-axes = "~5.32.0" pydantic = "^2.12.0" +pydantic-collections = "^0.6.0" pyngo = "~2.4.1" -flagsmith = "^3.10.0" +flagsmith = "^5.1.1" python-gnupg = "^0.5.1" django-redis = "^5.4.0" pygithub = "2.1.1" diff --git a/api/tests/integration/edge_api/identities/conftest.py b/api/tests/integration/edge_api/identities/conftest.py index 8b800d096a96..70accd3e62d5 100644 --- a/api/tests/integration/edge_api/identities/conftest.py +++ b/api/tests/integration/edge_api/identities/conftest.py @@ -2,13 +2,13 @@ import pytest from boto3.dynamodb.conditions import Key -from flag_engine.identities.models import IdentityModel from edge_api.identities.models import EdgeIdentity from environments.dynamodb.wrappers.environment_wrapper import ( DynamoEnvironmentV2Wrapper, ) from users.models import FFAdminUser +from util.engine_models.identities.models import IdentityModel @pytest.fixture() diff --git a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py index 5e7496829dbe..d856a7bad9a2 100644 --- a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py +++ b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py @@ -6,13 +6,6 @@ import pytest from django.urls import reverse -from flag_engine.features.models import ( - FeatureModel, - FeatureStateModel, - MultivariateFeatureOptionModel, - MultivariateFeatureStateValueList, - MultivariateFeatureStateValueModel, -) from mypy_boto3_dynamodb.service_resource import Table from mypy_boto3_dynamodb.type_defs import TableAttributeValueTypeDef from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] @@ -37,6 +30,13 @@ from features.multivariate.models import MultivariateFeatureOption from projects.models import Project from tests.integration.helpers import create_mv_option_with_api +from util.engine_models.features.models import ( + FeatureModel, + FeatureStateModel, + MultivariateFeatureOptionModel, + MultivariateFeatureStateValueList, + MultivariateFeatureStateValueModel, +) from util.mappers.engine import map_feature_to_engine diff --git a/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py b/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py index efc68754ce01..8c547014b481 100644 --- a/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py +++ b/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py @@ -1,7 +1,6 @@ import pytest from django.test import RequestFactory from django.utils import timezone -from flag_engine.features.models import FeatureModel, FeatureStateModel from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from pytest_mock import MockerFixture @@ -15,6 +14,7 @@ from features.feature_types import STANDARD from features.models import Feature, FeatureState from users.models import FFAdminUser +from util.engine_models.features.models import FeatureModel, FeatureStateModel from util.mappers import map_identity_to_identity_document from webhooks.constants import WEBHOOK_DATETIME_FORMAT diff --git a/api/tests/unit/edge_api/identities/test_edge_identity_models.py b/api/tests/unit/edge_api/identities/test_edge_identity_models.py index 1154b6bd1ed7..ea1f5432b864 100644 --- a/api/tests/unit/edge_api/identities/test_edge_identity_models.py +++ b/api/tests/unit/edge_api/identities/test_edge_identity_models.py @@ -4,7 +4,6 @@ import pytest import shortuuid from django.utils import timezone -from flag_engine.features.models import FeatureModel, FeatureStateModel from freezegun import freeze_time from pytest_django import DjangoAssertNumQueries from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] @@ -18,6 +17,7 @@ from features.workflows.core.models import ChangeRequest from segments.models import Segment from users.models import FFAdminUser +from util.engine_models.features.models import FeatureModel, FeatureStateModel def test_get_all_feature_states_for_edge_identity_uses_segment_priorities( # type: ignore[no-untyped-def] diff --git a/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py b/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py index 211ef39cd1c5..12c5d0e385e0 100644 --- a/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py +++ b/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py @@ -4,7 +4,6 @@ import pytest from boto3.dynamodb.conditions import Key from django.core.exceptions import ObjectDoesNotExist -from flag_engine.identities.models import IdentityModel from flag_engine.segments.constants import IN from mypy_boto3_dynamodb.service_resource import Table from pytest_mock import MockerFixture @@ -20,7 +19,13 @@ from environments.dynamodb.wrappers.exceptions import CapacityBudgetExceeded from environments.identities.models import Identity from environments.identities.traits.models import Trait +from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import ( + MultivariateFeatureOption, + MultivariateFeatureStateValue, +) from segments.models import Condition, Segment, SegmentRule +from util.engine_models.identities.models import IdentityModel from util.mappers import ( map_environment_to_environment_document, map_identity_to_identity_document, @@ -330,6 +335,76 @@ def test_get_segment_ids_returns_correct_segment_ids( # type: ignore[no-untyped ) +def test_get_segment_ids_with_segment_feature_overrides( + project: "Project", + environment: "Environment", + feature: "Feature", + identity: "Identity", + identity_matching_segment: "Segment", + mocker: "MockerFixture", +) -> None: + # Given - a segment with two feature overrides: + # one simple override and one with multivariate values + simple_feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=identity_matching_segment, + environment=environment, + ) + FeatureState.objects.create( + feature=feature, + environment=environment, + feature_segment=simple_feature_segment, + enabled=True, + ) + + mv_feature = Feature.objects.create( + name="mv_feature", + project=project, + type="MULTIVARIATE", + ) + mv_option = MultivariateFeatureOption.objects.create( + feature=mv_feature, + default_percentage_allocation=30, + type="unicode", + string_value="variant_a", + ) + mv_feature_segment = FeatureSegment.objects.create( + feature=mv_feature, + segment=identity_matching_segment, + environment=environment, + ) + mv_feature_state = FeatureState.objects.create( + feature=mv_feature, + environment=environment, + feature_segment=mv_feature_segment, + enabled=True, + ) + MultivariateFeatureStateValue.objects.create( + feature_state=mv_feature_state, + multivariate_feature_option=mv_option, + percentage_allocation=30, + ) + + identity_document = map_identity_to_identity_document(identity) + dynamo_identity_wrapper = DynamoIdentityWrapper() + mocker.patch.object( + dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document + ) + identity_uuid = identity_document["identity_uuid"] + + environment_document = map_environment_to_environment_document(environment) + mocked_environment_wrapper = mocker.patch( + "environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper" + ) + mocked_environment_wrapper.return_value.get_item.return_value = environment_document + + # When + segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type] + + # Then + assert segment_ids == [identity_matching_segment.id] + + def test_get_segment_ids_returns_segment_using_in_operator_for_integer_traits( project: "Project", environment: "Environment", mocker: "MockerFixture" ) -> None: diff --git a/api/tests/unit/util/engine_models/identities/traits/test_unit_traits_types.py b/api/tests/unit/util/engine_models/identities/traits/test_unit_traits_types.py new file mode 100644 index 000000000000..3b17c76f924e --- /dev/null +++ b/api/tests/unit/util/engine_models/identities/traits/test_unit_traits_types.py @@ -0,0 +1,29 @@ +import pytest + +from util.engine_models.identities.traits.types import ( + map_any_value_to_trait_value, +) + + +@pytest.mark.parametrize( + "value, expected", + [ + # String values that look like integers should be converted to int + ("123", 123), + ("-45", -45), + ("0", 0), + # String values that look like floats should be converted to float + ("1.23", 1.23), + ("-4.56", -4.56), + ("0.0", 0.0), + # Non-trait-value types should be converted to string + (["a", "list"], "['a', 'list']"), + ({"a": "dict"}, "{'a': 'dict'}"), + ], +) +def test_map_any_value_to_trait_value(value: object, expected: object) -> None: + # When + result = map_any_value_to_trait_value(value) + + # Then + assert result == expected diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index 3bd67d1ec3a8..52cd0f04895b 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -4,44 +4,44 @@ import pytest import pytz from django.utils import timezone -from flag_engine.environments.integrations.models import IntegrationModel -from flag_engine.environments.models import ( +from pytest_mock import MockerFixture + +from environments.models import Environment +from features.models import FeatureSegment, FeatureState +from features.versioning.models import EnvironmentFeatureVersion +from features.versioning.tasks import enable_v2_versioning +from integrations.common.models import IntegrationsModel +from integrations.dynatrace.models import DynatraceConfiguration +from integrations.mixpanel.models import MixpanelConfiguration +from integrations.segment.models import SegmentConfiguration +from integrations.webhook.models import WebhookConfiguration +from segments.models import Segment, SegmentRule +from users.models import FFAdminUser +from util.engine_models.environments.integrations.models import IntegrationModel +from util.engine_models.environments.models import ( EnvironmentAPIKeyModel, EnvironmentModel, WebhookModel, ) -from flag_engine.features.models import ( +from util.engine_models.features.models import ( FeatureModel, FeatureSegmentModel, FeatureStateModel, MultivariateFeatureOptionModel, MultivariateFeatureStateValueModel, ) -from flag_engine.identities.models import ( # type: ignore[attr-defined] +from util.engine_models.identities.models import ( IdentityFeaturesList, IdentityModel, - TraitModel, ) -from flag_engine.organisations.models import OrganisationModel -from flag_engine.projects.models import ProjectModel -from flag_engine.segments.models import ( +from util.engine_models.identities.traits.models import TraitModel +from util.engine_models.organisations.models import OrganisationModel +from util.engine_models.projects.models import ProjectModel +from util.engine_models.segments.models import ( SegmentConditionModel, SegmentModel, SegmentRuleModel, ) -from pytest_mock import MockerFixture - -from environments.models import Environment -from features.models import FeatureSegment, FeatureState -from features.versioning.models import EnvironmentFeatureVersion -from features.versioning.tasks import enable_v2_versioning -from integrations.common.models import IntegrationsModel -from integrations.dynatrace.models import DynatraceConfiguration -from integrations.mixpanel.models import MixpanelConfiguration -from integrations.segment.models import SegmentConfiguration -from integrations.webhook.models import WebhookConfiguration -from segments.models import Segment, SegmentRule -from users.models import FFAdminUser from util.mappers import engine if TYPE_CHECKING: diff --git a/api/util/engine_models/environments/models.py b/api/util/engine_models/environments/models.py index 4a043587ecd3..697e79077465 100644 --- a/api/util/engine_models/environments/models.py +++ b/api/util/engine_models/environments/models.py @@ -19,12 +19,6 @@ class EnvironmentAPIKeyModel(BaseModel): expires_at: typing.Optional[datetime] = None active: bool = True - @property - def is_valid(self) -> bool: - return self.active and ( - not self.expires_at or self.expires_at > utcnow_with_tz() - ) - class WebhookModel(BaseModel): url: str diff --git a/api/util/engine_models/features/models.py b/api/util/engine_models/features/models.py index 96c6bc1bed74..c978e81e150b 100644 --- a/api/util/engine_models/features/models.py +++ b/api/util/engine_models/features/models.py @@ -15,12 +15,6 @@ class FeatureModel(BaseModel): name: str type: str - def __eq__(self, other: object) -> bool: - return isinstance(other, FeatureModel) and self.id == other.id - - def __hash__(self) -> int: - return hash(self.id) - class MultivariateFeatureOptionModel(BaseModel): value: typing.Any @@ -126,4 +120,4 @@ def _mv_fs_sort_key(mv_value: MultivariateFeatureStateValueModel) -> SupportsLt: # default to return the control value if no MV values found, although this # should never happen - return self.feature_state_value + return self.feature_state_value # pragma: no cover diff --git a/api/util/engine_models/organisations/models.py b/api/util/engine_models/organisations/models.py index 6599a1a78f7d..1d1e9194aa9e 100644 --- a/api/util/engine_models/organisations/models.py +++ b/api/util/engine_models/organisations/models.py @@ -7,7 +7,3 @@ class OrganisationModel(BaseModel): feature_analytics: bool = False stop_serving_flags: bool = False persist_trait_data: bool = True - - @property - def unique_slug(self) -> str: - return str(self.id) + "-" + self.name diff --git a/api/util/engine_models/segments/models.py b/api/util/engine_models/segments/models.py index 9e2f8d87ff05..015ca8846e07 100644 --- a/api/util/engine_models/segments/models.py +++ b/api/util/engine_models/segments/models.py @@ -1,6 +1,5 @@ import typing -from flag_engine.segments import constants from flag_engine.segments.types import ConditionOperator, RuleType from pydantic import BaseModel, BeforeValidator, Field from typing_extensions import Annotated @@ -21,18 +20,6 @@ class SegmentRuleModel(BaseModel): rules: typing.List["SegmentRuleModel"] = Field(default_factory=list) conditions: typing.List[SegmentConditionModel] = Field(default_factory=list) - @staticmethod - def none(iterable: typing.Iterable[object]) -> bool: - return not any(iterable) - - @property - def matching_function(self) -> typing.Callable[[typing.Iterable[object]], bool]: - return { - constants.ANY_RULE: any, - constants.ALL_RULE: all, - constants.NONE_RULE: SegmentRuleModel.none, - }[self.type] - class SegmentModel(BaseModel): id: int diff --git a/api/util/engine_models/utils/hashing.py b/api/util/engine_models/utils/hashing.py index e8bc991f5452..d998997546cb 100644 --- a/api/util/engine_models/utils/hashing.py +++ b/api/util/engine_models/utils/hashing.py @@ -20,7 +20,7 @@ def get_hashed_percentage_for_object_ids( hashed_value_as_int = int(hashed_value.hexdigest(), base=16) value = ((hashed_value_as_int % 9999) / 9998) * 100 - if value == 100: + if value == 100: # pragma: no cover # since we want a number between 0 (inclusive) and 100 (exclusive), in the # unlikely case that we get the exact number 100, we call the method again # and increase the number of iterations to ensure we get a different result diff --git a/api/util/mappers/dynamodb.py b/api/util/mappers/dynamodb.py index 8370eecd915f..6be7ebd87015 100644 --- a/api/util/mappers/dynamodb.py +++ b/api/util/mappers/dynamodb.py @@ -2,7 +2,6 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any, Dict, List, TypeAlias, TypeVar, Union -from flag_engine.features.models import FeatureStateModel from pydantic import BaseModel from edge_api.identities.types import IdentityChangeset @@ -16,6 +15,7 @@ from environments.dynamodb.utils import ( get_environments_v2_identity_override_document_key, ) +from util.engine_models.features.models import FeatureStateModel from util.mappers.engine import ( map_environment_api_key_to_engine, map_environment_to_engine, @@ -23,10 +23,9 @@ ) if TYPE_CHECKING: - from flag_engine.identities.models import IdentityModel - from environments.identities.models import Identity from environments.models import Environment, EnvironmentAPIKey + from util.engine_models.identities.models import IdentityModel __all__ = ( diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index a8ced81fbdb3..3d6a06c5c694 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -4,34 +4,32 @@ from uuid import UUID from flag_engine.context.types import EvaluationContext -from flag_engine.environments.integrations.models import IntegrationModel -from flag_engine.environments.models import ( + +from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES +from features.versioning.models import EnvironmentFeatureVersion +from util.engine_models.environments.integrations.models import IntegrationModel +from util.engine_models.environments.models import ( EnvironmentAPIKeyModel, EnvironmentModel, WebhookModel, ) -from flag_engine.features.models import ( +from util.engine_models.features.models import ( FeatureModel, FeatureSegmentModel, FeatureStateModel, MultivariateFeatureOptionModel, MultivariateFeatureStateValueModel, ) -from flag_engine.identities.models import ( # type: ignore[attr-defined] - IdentityModel, - TraitModel, -) -from flag_engine.organisations.models import OrganisationModel -from flag_engine.projects.models import ProjectModel -from flag_engine.segments.models import ( +from util.engine_models.identities.models import IdentityModel +from util.engine_models.identities.traits.models import TraitModel +from util.engine_models.organisations.models import OrganisationModel +from util.engine_models.projects.models import ProjectModel +from util.engine_models.segments.models import ( SegmentConditionModel, SegmentModel, SegmentRuleModel, ) -from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES -from features.versioning.models import EnvironmentFeatureVersion - if TYPE_CHECKING: # pragma: no cover from environments.identities.models import ( # type: ignore[attr-defined] Identity, From 22a0066480eb73299978004047f58f2e20c1df66 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 4 Feb 2026 17:16:06 +0530 Subject: [PATCH 3/8] chore: regenerate OpenAPI spec for flag-engine v10 compatibility Update trait_value type ordering and remove maxLength constraint to match the ContextValue type definition in flag-engine v10. --- sdk/openapi.yaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/sdk/openapi.yaml b/sdk/openapi.yaml index 6891cf57b0d4..1583ecaab9de 100644 --- a/sdk/openapi.yaml +++ b/sdk/openapi.yaml @@ -442,11 +442,10 @@ components: title: Trait Key trait_value: anyOf: - - type: boolean - - type: number - type: integer - - maxLength: 2000 - type: string + - type: number + - type: boolean + - type: string - type: 'null' title: Trait Value transient: @@ -511,11 +510,10 @@ components: title: Trait Key trait_value: anyOf: - - type: boolean - - type: number - type: integer - - maxLength: 2000 - type: string + - type: number + - type: boolean + - type: string - type: 'null' title: Trait Value required: From 2c47f9b7ef2dfb6d1edc561fd819e1c5d6fff5b9 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 4 Feb 2026 17:21:17 +0530 Subject: [PATCH 4/8] chore: remove unused FeatureStateNotFound exception --- api/util/engine_models/utils/exceptions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/util/engine_models/utils/exceptions.py b/api/util/engine_models/utils/exceptions.py index 98821885c7c4..279e8aef095b 100644 --- a/api/util/engine_models/utils/exceptions.py +++ b/api/util/engine_models/utils/exceptions.py @@ -1,7 +1,3 @@ -class FeatureStateNotFound(Exception): - pass - - class DuplicateFeatureState(ValueError): pass From b2614b63963e1bc84aeea004ed9ba2ec2ca33af2 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 4 Feb 2026 17:37:56 +0530 Subject: [PATCH 5/8] test: add coverage for Identity.__str__ method --- .../identities/test_unit_identities_models.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/tests/unit/environments/identities/test_unit_identities_models.py b/api/tests/unit/environments/identities/test_unit_identities_models.py index ca3a889f0326..a2a435f1b917 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_models.py +++ b/api/tests/unit/environments/identities/test_unit_identities_models.py @@ -43,6 +43,21 @@ def test_create_identity_should_assign_relevant_attributes( assert hasattr(identity, "created_date") +def test_identity_str__returns_account_identifier( + environment: Environment, +) -> None: + # Given + identity = Identity.objects.create( + identifier="test-identity", environment=environment + ) + + # When + result = str(identity) + + # Then + assert result == "Account test-identity" + + def test_get_all_feature_states( project: Project, environment: Environment, From 07739da4d6c41a287d39fb7d1447fd609d32e9c6 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 5 Feb 2026 08:58:55 +0530 Subject: [PATCH 6/8] test: temporarily disable codecov upload from private packages workflow Testing hypothesis that merged coverage from private packages workflow is affecting the codecov/project check. --- .../api-tests-with-private-packages.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/api-tests-with-private-packages.yml b/.github/workflows/api-tests-with-private-packages.yml index 69cac3347aa8..b1d6314d4790 100644 --- a/.github/workflows/api-tests-with-private-packages.yml +++ b/.github/workflows/api-tests-with-private-packages.yml @@ -65,11 +65,12 @@ jobs: DOTENV_OVERRIDE_FILE: .env-ci run: make test - - name: Upload Coverage - uses: codecov/codecov-action@v4 - env: - PRIVATE_PACKAGES: "true" - PYTHON: ${{ matrix.python-version }} - with: - env_vars: PRIVATE_PACKAGES,PYTHON - use_oidc: true + # Temporarily disabled to test codecov hypothesis + # - name: Upload Coverage + # uses: codecov/codecov-action@v4 + # env: + # PRIVATE_PACKAGES: "true" + # PYTHON: ${{ matrix.python-version }} + # with: + # env_vars: PRIVATE_PACKAGES,PYTHON + # use_oidc: true From 4a959d1d44f042e1b76bc08a96395ba181113139 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 5 Feb 2026 10:07:45 +0530 Subject: [PATCH 7/8] Revert "test: temporarily disable codecov upload from private packages workflow" This reverts commit 07739da4d6c41a287d39fb7d1447fd609d32e9c6. --- .../api-tests-with-private-packages.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/api-tests-with-private-packages.yml b/.github/workflows/api-tests-with-private-packages.yml index b1d6314d4790..69cac3347aa8 100644 --- a/.github/workflows/api-tests-with-private-packages.yml +++ b/.github/workflows/api-tests-with-private-packages.yml @@ -65,12 +65,11 @@ jobs: DOTENV_OVERRIDE_FILE: .env-ci run: make test - # Temporarily disabled to test codecov hypothesis - # - name: Upload Coverage - # uses: codecov/codecov-action@v4 - # env: - # PRIVATE_PACKAGES: "true" - # PYTHON: ${{ matrix.python-version }} - # with: - # env_vars: PRIVATE_PACKAGES,PYTHON - # use_oidc: true + - name: Upload Coverage + uses: codecov/codecov-action@v4 + env: + PRIVATE_PACKAGES: "true" + PYTHON: ${{ matrix.python-version }} + with: + env_vars: PRIVATE_PACKAGES,PYTHON + use_oidc: true From 16f2313fdc3f2dfc527a91c1157fe4dc172120d4 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Fri, 6 Feb 2026 08:04:32 +0530 Subject: [PATCH 8/8] refactor: use Protocol type for environment parameter in context mapper - Replace typing.Any with EnvironmentProtocol for type safety - Add TODO for migrating to get_evaluation_result (#6669) --- api/util/engine_models/context/mappers.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/api/util/engine_models/context/mappers.py b/api/util/engine_models/context/mappers.py index fe1ca58638d0..53df05b94909 100644 --- a/api/util/engine_models/context/mappers.py +++ b/api/util/engine_models/context/mappers.py @@ -6,6 +6,7 @@ """ import typing +from typing import Protocol from flag_engine.context.types import ( EvaluationContext, @@ -23,8 +24,13 @@ from util.engine_models.segments.models import SegmentModel, SegmentRuleModel +class EnvironmentProtocol(Protocol): + api_key: str + name: str | None + + def map_environment_identity_to_context( - environment: typing.Any, # Accepts both Django Environment and Pydantic EnvironmentModel + environment: EnvironmentProtocol, identity: IdentityModel, override_traits: typing.Optional[typing.List[TraitModel]], ) -> EvaluationContext: @@ -34,11 +40,8 @@ def map_environment_identity_to_context( Vendored from flagsmith-flag-engine's fix/missing-export branch and adapted to return v10's EvaluationContext TypedDict. - This function uses duck typing - it only accesses `api_key` and `name` - attributes from the environment, which exist on both Django Environment - models and Pydantic EnvironmentModel objects. - - :param environment: The environment object (Django or Pydantic). + :param environment: Any object with `api_key` and `name` attributes + (e.g. Django Environment or Pydantic EnvironmentModel). :param identity: The identity model object (Pydantic IdentityModel). :param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided. @@ -160,6 +163,7 @@ def map_segment_to_segment_context(segment: SegmentModel) -> SegmentContext: return segment_ctx +# TODO: Migrate to get_evaluation_result - see #6669 def is_context_in_segment( context: EvaluationContext, segment: SegmentModel,