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 441248990516..4d0d1ae1a63b 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 b49b6aff9430..775a0f56c680 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -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"}, @@ -941,7 +941,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"}, @@ -1987,22 +1987,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" @@ -2062,24 +2062,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 = "7a15176f25bd00c3d93a8e56238382f7818fa557" +jsonpath-rfc9535 = ">=0.1.5,<1" +semver = ">=3.0.4,<4" +typing-extensions = ">=4.14.1,<5" [[package]] name = "flagsmith-ldap" @@ -2512,6 +2508,31 @@ files = [ [package.dependencies] pygments = "*" +[[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" @@ -2592,6 +2613,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" @@ -3472,7 +3509,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"}, @@ -3491,14 +3528,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] @@ -3511,7 +3548,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"}, @@ -4259,6 +4296,130 @@ 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.2.19" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.10" +groups = ["main", "workflows"] +files = [ + {file = "regex-2026.2.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f5a37a17d110f9d5357a43aa7e3507cb077bf3143d1c549a45c4649e90e40a70"}, + {file = "regex-2026.2.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:676c4e6847a83a1d5732b4ed553881ad36f0a8133627bb695a89ecf3571499d3"}, + {file = "regex-2026.2.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82336faeecac33297cd42857c3b36f12b91810e3fdd276befdd128f73a2b43fa"}, + {file = "regex-2026.2.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52136f5b71f095cb74b736cc3a1b578030dada2e361ef2f07ca582240b703946"}, + {file = "regex-2026.2.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4192464fe3e6cb0ef6751f7d3b16f886d8270d359ed1590dd555539d364f0ff7"}, + {file = "regex-2026.2.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e561dd47a85d2660d3d3af4e6cb2da825cf20f121e577147963f875b83d32786"}, + {file = "regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00ec994d7824bf01cd6c7d14c7a6a04d9aeaf7c42a2bc22d2359d715634d539b"}, + {file = "regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2cb00aabd96b345d56a8c2bc328c8d6c4d29935061e05078bf1f02302e12abf5"}, + {file = "regex-2026.2.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f374366ed35673ea81b86a8859c457d4fae6ba092b71024857e9e237410c7404"}, + {file = "regex-2026.2.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9417fd853fcd00b7d55167e692966dd12d95ba1a88bf08a62002ccd85030790"}, + {file = "regex-2026.2.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12e86a01594031abf892686fcb309b041bf3de3d13d99eb7e2b02a8f3c687df1"}, + {file = "regex-2026.2.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:79014115e6fdf18fd9b32e291d58181bf42d4298642beaa13fd73e69810e4cb6"}, + {file = "regex-2026.2.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31aefac2506967b7dd69af2c58eca3cc8b086d4110b66d6ac6e9026f0ee5b697"}, + {file = "regex-2026.2.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49cef7bb2a491f91a8869c7cdd90babf0a417047ab0bf923cd038ed2eab2ccb8"}, + {file = "regex-2026.2.19-cp310-cp310-win32.whl", hash = "sha256:3a039474986e7a314ace6efb9ce52f5da2bdb80ac4955358723d350ec85c32ad"}, + {file = "regex-2026.2.19-cp310-cp310-win_amd64.whl", hash = "sha256:5b81ff4f9cad99f90c807a00c5882fbcda86d8b3edd94e709fb531fc52cb3d25"}, + {file = "regex-2026.2.19-cp310-cp310-win_arm64.whl", hash = "sha256:a032bc01a4bc73fc3cadba793fce28eb420da39338f47910c59ffcc11a5ba5ef"}, + {file = "regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc"}, + {file = "regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be"}, + {file = "regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2"}, + {file = "regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906"}, + {file = "regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726"}, + {file = "regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d"}, + {file = "regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083"}, + {file = "regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e"}, + {file = "regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18"}, + {file = "regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32"}, + {file = "regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7"}, + {file = "regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e"}, + {file = "regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0"}, + {file = "regex-2026.2.19-cp311-cp311-win32.whl", hash = "sha256:66e6a43225ff1064f8926adbafe0922b370d381c3330edaf9891cade52daa790"}, + {file = "regex-2026.2.19-cp311-cp311-win_amd64.whl", hash = "sha256:59a7a5216485a1896c5800e9feb8ff9213e11967b482633b6195d7da11450013"}, + {file = "regex-2026.2.19-cp311-cp311-win_arm64.whl", hash = "sha256:ec661807ffc14c8d14bb0b8c1bb3d5906e476bc96f98b565b709d03962ee4dd4"}, + {file = "regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc"}, + {file = "regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8"}, + {file = "regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53"}, + {file = "regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6"}, + {file = "regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65"}, + {file = "regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332"}, + {file = "regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06"}, + {file = "regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774"}, + {file = "regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668"}, + {file = "regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9"}, + {file = "regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6"}, + {file = "regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c"}, + {file = "regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a"}, + {file = "regex-2026.2.19-cp312-cp312-win32.whl", hash = "sha256:5df947cabab4b643d4791af5e28aecf6bf62e6160e525651a12eba3d03755e6b"}, + {file = "regex-2026.2.19-cp312-cp312-win_amd64.whl", hash = "sha256:4146dc576ea99634ae9c15587d0c43273b4023a10702998edf0fa68ccb60237a"}, + {file = "regex-2026.2.19-cp312-cp312-win_arm64.whl", hash = "sha256:cdc0a80f679353bd68450d2a42996090c30b2e15ca90ded6156c31f1a3b63f3b"}, + {file = "regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879"}, + {file = "regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64"}, + {file = "regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968"}, + {file = "regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13"}, + {file = "regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02"}, + {file = "regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161"}, + {file = "regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7"}, + {file = "regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1"}, + {file = "regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4"}, + {file = "regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c"}, + {file = "regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f"}, + {file = "regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed"}, + {file = "regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a"}, + {file = "regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b"}, + {file = "regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47"}, + {file = "regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e"}, + {file = "regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9"}, + {file = "regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7"}, + {file = "regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60"}, + {file = "regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f"}, + {file = "regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007"}, + {file = "regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e"}, + {file = "regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619"}, + {file = "regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555"}, + {file = "regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1"}, + {file = "regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5"}, + {file = "regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04"}, + {file = "regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3"}, + {file = "regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743"}, + {file = "regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db"}, + {file = "regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768"}, + {file = "regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7"}, + {file = "regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919"}, + {file = "regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e"}, + {file = "regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5"}, + {file = "regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e"}, + {file = "regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a"}, + {file = "regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73"}, + {file = "regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f"}, + {file = "regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265"}, + {file = "regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a"}, + {file = "regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c"}, + {file = "regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799"}, + {file = "regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c"}, + {file = "regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e"}, + {file = "regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d"}, + {file = "regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904"}, + {file = "regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b"}, + {file = "regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175"}, + {file = "regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411"}, + {file = "regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b"}, + {file = "regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83"}, + {file = "regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3"}, + {file = "regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867"}, + {file = "regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a"}, + {file = "regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd"}, + {file = "regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe"}, + {file = "regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969"}, + {file = "regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876"}, + {file = "regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854"}, + {file = "regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868"}, + {file = "regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01"}, + {file = "regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3"}, + {file = "regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0"}, + {file = "regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310"}, +] + [[package]] name = "requests" version = "2.32.4" @@ -4670,14 +4831,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]] @@ -5254,7 +5415,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"}, @@ -5531,4 +5692,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.14" -content-hash = "a1be7c3a00f403216a1da948ee0302cefc7a7f6885b505c6ae124cc25c8e5f5a" +content-hash = "cdb9804a87f2b7270e4b3cfca1a97e73de5710a6c06db6ba1542b43ebee88e86" diff --git a/api/pyproject.toml b/api/pyproject.toml index 05e5a7142c21..fe2354f0462c 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -137,7 +137,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" @@ -154,8 +154,9 @@ django-ordered-model = "~3.4.1" django-ses = "~3.5.0" django-axes = "^8.1.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/environments/identities/test_unit_identities_models.py b/api/tests/unit/environments/identities/test_unit_identities_models.py index 1772c2232dcf..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, @@ -1024,18 +1039,3 @@ def test_identity_get_all_feature_states__returns_identity_override__when_v2_fea # Then assert len(all_feature_states) == 1 assert all_feature_states[0] == identity_override - - -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" 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/__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..53df05b94909 --- /dev/null +++ b/api/util/engine_models/context/mappers.py @@ -0,0 +1,202 @@ +""" +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 typing import Protocol + +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 + + +class EnvironmentProtocol(Protocol): + api_key: str + name: str | None + + +def map_environment_identity_to_context( + environment: EnvironmentProtocol, + 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. + + :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. + :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 + + +# TODO: Migrate to get_evaluation_result - see #6669 +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..697e79077465 --- /dev/null +++ b/api/util/engine_models/environments/models.py @@ -0,0 +1,50 @@ +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 + + +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..c978e81e150b --- /dev/null +++ b/api/util/engine_models/features/models.py @@ -0,0 +1,123 @@ +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 + + +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 # pragma: no cover 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..1d1e9194aa9e --- /dev/null +++ b/api/util/engine_models/organisations/models.py @@ -0,0 +1,9 @@ +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 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..015ca8846e07 --- /dev/null +++ b/api/util/engine_models/segments/models.py @@ -0,0 +1,28 @@ +import typing + +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) + + +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..279e8aef095b --- /dev/null +++ b/api/util/engine_models/utils/exceptions.py @@ -0,0 +1,6 @@ +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..d998997546cb --- /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: # 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 + return get_hashed_percentage_for_object_ids( + object_ids=object_ids, iterations=iterations + 1 + ) + + return value 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, 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: