From 25476b7e015b4629855c6c99d29225f36576d72e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 30 Dec 2025 15:57:49 +0000 Subject: [PATCH 1/6] feat: Annotate DynamoDB schemas with validation code --- src/flagsmith_schemas/constants.py | 3 + src/flagsmith_schemas/dynamodb.py | 29 +++-- src/flagsmith_schemas/pydantic_types.py | 20 ++++ src/flagsmith_schemas/types.py | 25 +++-- src/flagsmith_schemas/validators.py | 30 +++++ .../flagsmith_schemas/test_dynamodb.py | 104 +++++++++++++++++- 6 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 src/flagsmith_schemas/constants.py create mode 100644 src/flagsmith_schemas/pydantic_types.py create mode 100644 src/flagsmith_schemas/validators.py diff --git a/src/flagsmith_schemas/constants.py b/src/flagsmith_schemas/constants.py new file mode 100644 index 0000000..08d62c3 --- /dev/null +++ b/src/flagsmith_schemas/constants.py @@ -0,0 +1,3 @@ +from importlib.util import find_spec + +PYDANTIC_INSTALLED = find_spec("pydantic") is not None diff --git a/src/flagsmith_schemas/dynamodb.py b/src/flagsmith_schemas/dynamodb.py index 467a86b..8f137bb 100644 --- a/src/flagsmith_schemas/dynamodb.py +++ b/src/flagsmith_schemas/dynamodb.py @@ -1,24 +1,35 @@ """ The types in this module describe the Edge API's data model. They are used to type DynamoDB documents representing Flagsmith entities. + +These types can be used with Pydantic for validation and serialization +when `pydantic` is installed. +Otherwise, they serve as documentation for the structure of the data stored in DynamoDB. """ -from typing import Literal +from typing import Annotated, Literal from typing_extensions import NotRequired, TypedDict +from flagsmith_schemas.constants import PYDANTIC_INSTALLED from flagsmith_schemas.types import ( ConditionOperator, - ContextValue, DateTimeStr, + DynamoContextValue, + DynamoFeatureValue, DynamoFloat, DynamoInt, FeatureType, - FeatureValue, RuleType, UUIDStr, ) +if PYDANTIC_INSTALLED: + from flagsmith_schemas.pydantic_types import ( + ValidateIdentityFeatureStatesList, + ValidateMultivariateFeatureValuesList, + ) + class Feature(TypedDict): """Represents a Flagsmith feature, defined at project level.""" @@ -35,7 +46,7 @@ class MultivariateFeatureOption(TypedDict): id: NotRequired[DynamoInt | None] """Unique identifier for the multivariate feature option in Core. This is used by Core UI to display the selected option for an identity override for a multivariate feature.""" - value: FeatureValue + value: DynamoFeatureValue """The feature state value that should be served when this option's parent multivariate feature state is selected by the engine.""" @@ -69,7 +80,7 @@ class FeatureState(TypedDict): """The feature that this feature state is for.""" enabled: bool """Whether the feature is enabled or disabled.""" - feature_state_value: FeatureValue + feature_state_value: DynamoFeatureValue """The value for this feature state.""" django_id: NotRequired[DynamoInt | None] """Unique identifier for the feature state in Core. If feature state created via Core's `edge-identities` APIs in Core, this can be missing or `None`.""" @@ -77,7 +88,7 @@ class FeatureState(TypedDict): """The UUID for this feature state. Should be used if `django_id` is `None`. If not set, should be generated.""" feature_segment: NotRequired[FeatureSegment | None] """Segment override data, if this feature state is for a segment override.""" - multivariate_feature_state_values: NotRequired[list[MultivariateFeatureStateValue]] + multivariate_feature_state_values: "NotRequired[Annotated[list[MultivariateFeatureStateValue], ValidateMultivariateFeatureValuesList]]" """List of multivariate feature state values, if this feature state is for a multivariate feature. Total `percentage_allocation` sum of the child multivariate feature state values must be less or equal to 100. @@ -89,7 +100,7 @@ class Trait(TypedDict): trait_key: str """Key of the trait.""" - trait_value: ContextValue + trait_value: DynamoContextValue """Value of the trait.""" @@ -275,7 +286,9 @@ class Identity(TypedDict): """ created_date: DateTimeStr """Creation timestamp.""" - identity_features: NotRequired[list[FeatureState]] + identity_features: ( + "NotRequired[Annotated[list[FeatureState], ValidateIdentityFeatureStatesList]]" + ) """List of identity overrides for this identity.""" identity_traits: list[Trait] """List of traits associated with this identity.""" diff --git a/src/flagsmith_schemas/pydantic_types.py b/src/flagsmith_schemas/pydantic_types.py new file mode 100644 index 0000000..445df75 --- /dev/null +++ b/src/flagsmith_schemas/pydantic_types.py @@ -0,0 +1,20 @@ +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import AfterValidator, ValidateAs + +from flagsmith_schemas.validators import ( + validate_identity_feature_states, + validate_multivariate_feature_state_values, +) + +ValidateDecimalAsFloat = ValidateAs(float, lambda v: Decimal(str(v))) +ValidateDecimalAsInt = ValidateAs(int, lambda v: Decimal(v)) +ValidateStrAsISODateTime = ValidateAs(datetime, lambda dt: dt.isoformat()) +ValidateStrAsUUID = ValidateAs(UUID, str) + +ValidateIdentityFeatureStatesList = AfterValidator(validate_identity_feature_states) +ValidateMultivariateFeatureValuesList = AfterValidator( + validate_multivariate_feature_state_values +) diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index b6d628b..18f2078 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -1,36 +1,47 @@ from decimal import Decimal -from typing import Literal, TypeAlias +from typing import Annotated, Literal, TypeAlias -DynamoInt: TypeAlias = Decimal +from flagsmith_schemas.constants import PYDANTIC_INSTALLED + +if PYDANTIC_INSTALLED: + from flagsmith_schemas.pydantic_types import ( # noqa: F401 + ValidateDecimalAsFloat, + ValidateDecimalAsInt, + ValidateStrAsISODateTime, + ValidateStrAsUUID, + ) + + +DynamoInt: TypeAlias = Annotated[Decimal, "ValidateDecimalAsInt"] """An integer value stored in DynamoDB. DynamoDB represents all numbers as `Decimal`. `DynamoInt` indicates that the value should be treated as an integer. """ -DynamoFloat: TypeAlias = Decimal +DynamoFloat: TypeAlias = Annotated[Decimal, "ValidateDecimalAsFloat"] """A float value stored in DynamoDB. DynamoDB represents all numbers as `Decimal`. `DynamoFloat` indicates that the value should be treated as a float. """ -UUIDStr: TypeAlias = str +UUIDStr: TypeAlias = Annotated[str, "ValidateStrAsUUID"] """A string representing a UUID.""" -DateTimeStr: TypeAlias = str +DateTimeStr: TypeAlias = Annotated[str, "ValidateStrAsISODateTime"] """A string representing a date and time in ISO 8601 format.""" FeatureType = Literal["STANDARD", "MULTIVARIATE"] """Represents the type of a Flagsmith feature. Multivariate features include multiple weighted values.""" -FeatureValue: TypeAlias = object +DynamoFeatureValue: TypeAlias = DynamoInt | bool | str | None """Represents the value of a Flagsmith feature. Can be stored a boolean, an integer, or a string. The default (SaaS) maximum length for strings is 20000 characters. """ -ContextValue: TypeAlias = DynamoInt | DynamoFloat | bool | str +DynamoContextValue: TypeAlias = DynamoInt | DynamoFloat | bool | str """Represents a scalar value in the Flagsmith context, e.g., of an identity trait. Here's how we store different types: - Numeric string values (int, float) are stored as numbers. diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py new file mode 100644 index 0000000..3fc8423 --- /dev/null +++ b/src/flagsmith_schemas/validators.py @@ -0,0 +1,30 @@ +import typing + +if typing.TYPE_CHECKING: + from flagsmith_schemas.dynamodb import FeatureState, MultivariateFeatureStateValue + + +def validate_multivariate_feature_state_values( + values: "list[MultivariateFeatureStateValue]", +) -> "list[MultivariateFeatureStateValue]": + total_percentage = sum(value["percentage_allocation"] for value in values) + if total_percentage > 100: + raise ValueError( + "Total `percentage_allocation` of multivariate feature state values " + "cannot exceed 100." + ) + return values + + +def validate_identity_feature_states( + values: "list[FeatureState]", +) -> "list[FeatureState]": + for i, feature_state in enumerate(values, start=1): + if feature_state["feature"]["id"] in [ + feature_state["feature"]["id"] for feature_state in values[i:] + ]: + raise ValueError( + f"Feature id={feature_state['feature']['id']} cannot have multiple " + "feature states for a single identity." + ) + return values diff --git a/tests/integration/flagsmith_schemas/test_dynamodb.py b/tests/integration/flagsmith_schemas/test_dynamodb.py index 074b611..39dce91 100644 --- a/tests/integration/flagsmith_schemas/test_dynamodb.py +++ b/tests/integration/flagsmith_schemas/test_dynamodb.py @@ -1,7 +1,8 @@ from typing import TypeVar import pytest -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError +from pytest_mock import MockerFixture from flagsmith_schemas.dynamodb import ( Environment, @@ -513,3 +514,104 @@ def test_document__validate_json__expected_result( # Then assert document == expected_result + + +def test_type_adapter__identity__duplicate_features__raises_expected( + mocker: MockerFixture, +) -> None: + # Given + type_adapter = TypeAdapter(Identity) + python_data = { + "composite_key": "envkey_identifier", + "created_date": "2024-03-19T09:41:22.974595+00:00", + "environment_api_key": "envkey", + "identifier": "identifier", + "identity_uuid": "118ecfc9-5234-4218-8af8-dd994dbfedc0", + "identity_features": [ + { + "enabled": True, + "feature": {"id": 1, "name": "feature1", "type": "STANDARD"}, + "feature_state_value": None, + "multivariate_feature_state_values": [], + }, + { + "enabled": False, + "feature": {"id": 1, "name": "feature1", "type": "STANDARD"}, + "feature_state_value": "override", + "multivariate_feature_state_values": [], + }, + ], + "identity_traits": [], + } + + # When / Then + with pytest.raises(ValidationError) as exc_info: + type_adapter.validate_python(python_data) + + assert len(exc_info.value.errors()) == 1 + assert ( + exc_info.value.errors()[0].items() + >= { + "type": "value_error", + "loc": ("identity_features",), + "msg": "Value error, Feature id=1 cannot have multiple feature states for a single identity.", + }.items() + ) + + +def test_type_adapter__environment__multivariate_feature_states_percentage_allocation_exceeds_100__raises_expected() -> ( + None +): + # Given + type_adapter = TypeAdapter(Environment) + python_data = { + "id": 1, + "api_key": "envkey", + "project": { + "id": 1, + "name": "Project", + "organisation": { + "id": 1, + "name": "Org", + "feature_analytics": False, + "stop_serving_flags": False, + "persist_trait_data": True, + }, + "segments": [], + "hide_disabled_flags": False, + }, + "feature_states": [ + { + "feature": {"id": 1, "name": "mv_feature", "type": "MULTIVARIATE"}, + "enabled": True, + "feature_state_value": "some_value", + "django_id": 1, + "multivariate_feature_state_values": [ + { + "id": 1, + "percentage_allocation": 60.0, + "multivariate_feature_option": {"value": "option1"}, + }, + { + "id": 2, + "percentage_allocation": 50.0, + "multivariate_feature_option": {"value": "option2"}, + }, + ], + } + ], + } + + # When / Then + with pytest.raises(ValidationError) as exc_info: + type_adapter.validate_python(python_data) + + assert len(exc_info.value.errors()) == 1 + assert ( + exc_info.value.errors()[0].items() + >= { + "type": "value_error", + "loc": ("feature_states", 0, "multivariate_feature_state_values"), + "msg": "Value error, Total `percentage_allocation` of multivariate feature state values cannot exceed 100.", + }.items() + ) From a6f7bb9c65ec4999c509780b7db64937e3a85608 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 30 Dec 2025 17:22:19 +0000 Subject: [PATCH 2/6] fix tests, annotated types --- src/flagsmith_schemas/pydantic_types.py | 4 +- src/flagsmith_schemas/types.py | 31 +++-- src/flagsmith_schemas/validators.py | 12 ++ .../flagsmith_schemas/test_dynamodb.py | 115 +++++++++--------- 4 files changed, 97 insertions(+), 65 deletions(-) diff --git a/src/flagsmith_schemas/pydantic_types.py b/src/flagsmith_schemas/pydantic_types.py index 445df75..eae5656 100644 --- a/src/flagsmith_schemas/pydantic_types.py +++ b/src/flagsmith_schemas/pydantic_types.py @@ -2,9 +2,10 @@ from decimal import Decimal from uuid import UUID -from pydantic import AfterValidator, ValidateAs +from pydantic import AfterValidator, BeforeValidator, ValidateAs from flagsmith_schemas.validators import ( + validate_dynamo_feature_state_value, validate_identity_feature_states, validate_multivariate_feature_state_values, ) @@ -14,6 +15,7 @@ ValidateStrAsISODateTime = ValidateAs(datetime, lambda dt: dt.isoformat()) ValidateStrAsUUID = ValidateAs(UUID, str) +ValidateDynamoFeatureStateValue = BeforeValidator(validate_dynamo_feature_state_value) ValidateIdentityFeatureStatesList = AfterValidator(validate_identity_feature_states) ValidateMultivariateFeatureValuesList = AfterValidator( validate_multivariate_feature_state_values diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index 18f2078..83bb200 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -1,41 +1,54 @@ from decimal import Decimal -from typing import Annotated, Literal, TypeAlias +from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias from flagsmith_schemas.constants import PYDANTIC_INSTALLED if PYDANTIC_INSTALLED: - from flagsmith_schemas.pydantic_types import ( # noqa: F401 + from flagsmith_schemas.pydantic_types import ( ValidateDecimalAsFloat, ValidateDecimalAsInt, + ValidateDynamoFeatureStateValue, ValidateStrAsISODateTime, ValidateStrAsUUID, ) - - -DynamoInt: TypeAlias = Annotated[Decimal, "ValidateDecimalAsInt"] +elif not TYPE_CHECKING: + # This code runs at runtime when Pydantic is not installed. + # We could use PEP 649 strings with `Annotated`, but Pydantic is inconsistent in how it parses them. + # Define dummy types instead. + ValidateDecimalAsFloat = ... + ValidateDecimalAsInt = ... + ValidateDynamoFeatureStateValue = ... + ValidateStrAsISODateTime = ... + ValidateStrAsUUID = ... + + +DynamoInt: TypeAlias = Annotated[Decimal, ValidateDecimalAsInt] """An integer value stored in DynamoDB. DynamoDB represents all numbers as `Decimal`. `DynamoInt` indicates that the value should be treated as an integer. """ -DynamoFloat: TypeAlias = Annotated[Decimal, "ValidateDecimalAsFloat"] +DynamoFloat: TypeAlias = Annotated[Decimal, ValidateDecimalAsFloat] """A float value stored in DynamoDB. DynamoDB represents all numbers as `Decimal`. `DynamoFloat` indicates that the value should be treated as a float. """ -UUIDStr: TypeAlias = Annotated[str, "ValidateStrAsUUID"] +UUIDStr: TypeAlias = Annotated[str, ValidateStrAsUUID] """A string representing a UUID.""" -DateTimeStr: TypeAlias = Annotated[str, "ValidateStrAsISODateTime"] +DateTimeStr: TypeAlias = Annotated[str, ValidateStrAsISODateTime] """A string representing a date and time in ISO 8601 format.""" FeatureType = Literal["STANDARD", "MULTIVARIATE"] """Represents the type of a Flagsmith feature. Multivariate features include multiple weighted values.""" -DynamoFeatureValue: TypeAlias = DynamoInt | bool | str | None +DynamoFeatureValue: TypeAlias = Annotated[ + DynamoInt | bool | str | None, + ValidateDynamoFeatureStateValue, +] """Represents the value of a Flagsmith feature. Can be stored a boolean, an integer, or a string. The default (SaaS) maximum length for strings is 20000 characters. diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index 3fc8423..c3f1eac 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -1,7 +1,19 @@ import typing +from decimal import Decimal if typing.TYPE_CHECKING: from flagsmith_schemas.dynamodb import FeatureState, MultivariateFeatureStateValue + from flagsmith_schemas.types import DynamoFeatureValue + + +def validate_dynamo_feature_state_value( + value: typing.Any, +) -> "DynamoFeatureValue": + if isinstance(value, bool | str | None): + return value + if isinstance(value, int): + return Decimal(value) + return str(value) def validate_multivariate_feature_state_values( diff --git a/tests/integration/flagsmith_schemas/test_dynamodb.py b/tests/integration/flagsmith_schemas/test_dynamodb.py index 39dce91..041d22c 100644 --- a/tests/integration/flagsmith_schemas/test_dynamodb.py +++ b/tests/integration/flagsmith_schemas/test_dynamodb.py @@ -1,3 +1,4 @@ +from decimal import Decimal from typing import TypeVar import pytest @@ -23,13 +24,13 @@ Environment, "flagsmith_environments.json", { - "id": 12561, + "id": Decimal("12561"), "api_key": "n9fbf9h3v4fFgH3U3ngWhb", "project": { - "id": 5359, + "id": Decimal("5359"), "name": "Edge API Test Project", "organisation": { - "id": 13, + "id": Decimal("13"), "name": "Flagsmith", "feature_analytics": False, "stop_serving_flags": False, @@ -37,7 +38,7 @@ }, "segments": [ { - "id": 4267, + "id": Decimal("4267"), "name": "regular_segment", "rules": [ { @@ -88,19 +89,19 @@ "feature_states": [ { "feature": { - "id": 15058, + "id": Decimal("15058"), "name": "string_feature", "type": "STANDARD", }, "enabled": False, "feature_state_value": "segment_override", - "django_id": 81027, + "django_id": Decimal("81027"), "multivariate_feature_state_values": [], } ], }, { - "id": 4268, + "id": Decimal("4268"), "name": "10_percent", "rules": [ { @@ -124,19 +125,19 @@ "feature_states": [ { "feature": { - "id": 15060, + "id": Decimal("15060"), "name": "basic_flag", "type": "STANDARD", }, "enabled": True, "feature_state_value": "", - "django_id": 81026, + "django_id": Decimal("81026"), "multivariate_feature_state_values": [], } ], }, { - "id": 16, + "id": Decimal("16"), "name": "segment_two", "rules": [ { @@ -165,23 +166,23 @@ "feature_states": [ { "feature": { - "id": 15058, + "id": Decimal("15058"), "name": "string_feature", "type": "STANDARD", }, "enabled": True, "feature_state_value": "segment_two_override_priority_0", - "django_id": 78978, + "django_id": Decimal("78978"), "featurestate_uuid": UUIDStr( "1545809c-e97f-4a1f-9e67-8b4f2b396aa6" ), - "feature_segment": {"priority": 0}, + "feature_segment": {"priority": Decimal("0")}, "multivariate_feature_state_values": [], } ], }, { - "id": 17, + "id": Decimal("17"), "name": "segment_three", "rules": [ { @@ -210,17 +211,17 @@ "feature_states": [ { "feature": { - "id": 15058, + "id": Decimal("15058"), "name": "string_feature", "type": "STANDARD", }, "enabled": True, "feature_state_value": "segment_three_override_priority_1", - "django_id": 78977, + "django_id": Decimal("78977"), "featurestate_uuid": UUIDStr( "1545809c-e97f-4a1f-9e67-8b4f2b396aa7" ), - "feature_segment": {"priority": 1}, + "feature_segment": {"priority": Decimal("1")}, "multivariate_feature_state_values": [], } ], @@ -231,77 +232,77 @@ "feature_states": [ { "feature": { - "id": 15058, + "id": Decimal("15058"), "name": "string_feature", "type": "STANDARD", }, "enabled": True, "feature_state_value": "foo", - "django_id": 78978, + "django_id": Decimal("78978"), "feature_segment": None, "multivariate_feature_state_values": [], }, { "feature": { - "id": 15059, + "id": Decimal("15059"), "name": "integer_feature", "type": "STANDARD", }, "enabled": True, - "feature_state_value": 1234, - "django_id": 78980, + "feature_state_value": Decimal("1234"), + "django_id": Decimal("78980"), "multivariate_feature_state_values": [], }, { "feature": { - "id": 15060, + "id": Decimal("15060"), "name": "basic_flag", "type": "STANDARD", }, "enabled": False, "feature_state_value": None, - "django_id": 78982, + "django_id": Decimal("78982"), "multivariate_feature_state_values": [], }, { "feature": { - "id": 15061, + "id": Decimal("15061"), "name": "float_feature", "type": "STANDARD", }, "enabled": True, "feature_state_value": "12.34", - "django_id": 78984, + "django_id": Decimal("78984"), "multivariate_feature_state_values": [], }, { "feature": { - "id": 15062, + "id": Decimal("15062"), "name": "mv_feature", "type": "MULTIVARIATE", }, "enabled": True, "feature_state_value": "foo", - "django_id": 78986, + "django_id": Decimal("78986"), "multivariate_feature_state_values": [ { - "id": 3404, - "percentage_allocation": 30.0, + "id": Decimal("3404"), + "percentage_allocation": Decimal("30"), "multivariate_feature_option": {"value": "baz"}, }, { - "id": 3402, - "percentage_allocation": 30.0, + "id": Decimal("3402"), + "percentage_allocation": Decimal("30"), "multivariate_feature_option": {"value": "bar"}, }, { - "id": 3405, - "percentage_allocation": 0.0, - "multivariate_feature_option": {"value": 1}, + "id": Decimal("3405"), + "percentage_allocation": Decimal("0"), + "multivariate_feature_option": {"value": Decimal("1")}, }, { - "id": 3406, - "percentage_allocation": 0.0, + "id": Decimal("3406"), + "percentage_allocation": Decimal("0"), "multivariate_feature_option": {"value": True}, }, ], @@ -319,7 +320,7 @@ "client_api_key": "pQuzvsMLQoOVAwITrTWDQJ", "created_at": DateTimeStr("2023-04-21T13:11:13.913178+00:00"), "expires_at": None, - "id": 907, + "id": Decimal("907"), "name": "TestKey", }, id="flagsmith_environment_api_key", @@ -338,7 +339,7 @@ "django_id": None, "enabled": True, "feature": { - "id": 67, + "id": Decimal("67"), "name": "test_feature", "type": "STANDARD", }, @@ -367,10 +368,10 @@ "environment_api_key": "AQ9T6LixPqYMJkuqGJy3t2", "feature_states": [ { - "django_id": 577621, + "django_id": Decimal("577621"), "enabled": True, "feature": { - "id": 100298, + "id": Decimal("100298"), "name": "test_feature", "type": "MULTIVARIATE", }, @@ -381,34 +382,34 @@ "feature_state_value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "multivariate_feature_state_values": [ { - "id": 185130, + "id": Decimal("185130"), "multivariate_feature_option": { - "id": 20919, + "id": Decimal("20919"), "value": "second", }, "mv_fs_value_uuid": UUIDStr( "0b02ce41-9965-4c61-8b96-c8d76e3d4a27" ), - "percentage_allocation": 10.0, + "percentage_allocation": Decimal("10.0"), }, { - "id": 48717, + "id": Decimal("48717"), "multivariate_feature_option": { - "id": 14004, + "id": Decimal("14004"), "value": True, }, "mv_fs_value_uuid": UUIDStr( "cb05f49c-de1f-44f1-87eb-c3b55d473063" ), - "percentage_allocation": 30.0, + "percentage_allocation": Decimal("30.0"), }, ], }, { - "django_id": 1041292, + "django_id": Decimal("1041292"), "enabled": False, "feature": { - "id": 172422, + "id": Decimal("172422"), "name": "feature", "type": "STANDARD", }, @@ -416,24 +417,24 @@ "58b7b954-1b75-493a-82df-5be0efeedd2a" ), "feature_segment": None, - "feature_state_value": 3, + "feature_state_value": Decimal("3"), "multivariate_feature_state_values": [], }, ], "heap_config": None, "hide_disabled_flags": None, "hide_sensitive_data": False, - "id": 49268, + "id": Decimal("49268"), "mixpanel_config": None, "name": "Development", "project": { "enable_realtime_updates": False, "hide_disabled_flags": False, - "id": 19368, + "id": Decimal("19368"), "name": "Example Project", "organisation": { "feature_analytics": False, - "id": 13, + "id": Decimal("13"), "name": "Flagsmith", "persist_trait_data": True, "stop_serving_flags": False, @@ -441,7 +442,7 @@ "segments": [ { "feature_states": [], - "id": 44126, + "id": Decimal("44126"), "name": "test", "rules": [ { @@ -485,7 +486,11 @@ "feature_state": { "django_id": None, "enabled": True, - "feature": {"id": 136660, "name": "test1", "type": "STANDARD"}, + "feature": { + "id": Decimal("136660"), + "name": "test1", + "type": "STANDARD", + }, "featurestate_uuid": UUIDStr( "652d8931-37d9-438e-9825-f525b9e83077" ), From 6940b2b970731d66297ccbe8440691f89c171648 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 31 Dec 2025 11:08:27 +0000 Subject: [PATCH 3/6] Improve coverage --- .../flagsmith_schemas/test_dynamodb.py | 30 +++++++++++++++++++ .../flagsmith_schemas/test_types.py | 14 +++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/integration/flagsmith_schemas/test_types.py diff --git a/tests/integration/flagsmith_schemas/test_dynamodb.py b/tests/integration/flagsmith_schemas/test_dynamodb.py index 041d22c..aec403c 100644 --- a/tests/integration/flagsmith_schemas/test_dynamodb.py +++ b/tests/integration/flagsmith_schemas/test_dynamodb.py @@ -1,4 +1,6 @@ from decimal import Decimal +from importlib import reload +from sys import modules from typing import TypeVar import pytest @@ -620,3 +622,31 @@ def test_type_adapter__environment__multivariate_feature_states_percentage_alloc "msg": "Value error, Total `percentage_allocation` of multivariate feature state values cannot exceed 100.", }.items() ) + + +def test_import__no_pydantic__expected_annotations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given + # `pydantic` is not installed + monkeypatch.setitem(modules, "pydantic", None) + monkeypatch.setattr( + modules["flagsmith_schemas.constants"], + "PYDANTIC_INSTALLED", + False, + ) + reload(modules["flagsmith_schemas.types"]) + + # When + # importing `flagsmith_schemas.dynamodb` + dynamodb_module = reload(modules["flagsmith_schemas.dynamodb"]) + + # Then + # no `pydantic` references in type annotations + assert "pydantic" not in str(dynamodb_module.Identity.__annotations__) + assert "pydantic" not in str(dynamodb_module.Environment.__annotations__) + assert "pydantic" not in str(dynamodb_module.EnvironmentAPIKey.__annotations__) + assert "pydantic" not in str( + dynamodb_module.EnvironmentV2IdentityOverride.__annotations__ + ) + assert "pydantic" not in str(dynamodb_module.EnvironmentV2Meta.__annotations__) diff --git a/tests/integration/flagsmith_schemas/test_types.py b/tests/integration/flagsmith_schemas/test_types.py new file mode 100644 index 0000000..73ccd4a --- /dev/null +++ b/tests/integration/flagsmith_schemas/test_types.py @@ -0,0 +1,14 @@ +from pydantic import TypeAdapter + +from flagsmith_schemas.types import DynamoFeatureValue + + +def test_dynamo_feature_value__not_int__coerces_to_str() -> None: + # Given + type_adapter: TypeAdapter[DynamoFeatureValue] = TypeAdapter(DynamoFeatureValue) + + # When + result = type_adapter.validate_python(12.34) + + # Then + assert result == "12.34" From 959bc8c0f8c456265eeed65b2f8cc684f272b43e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 31 Dec 2025 11:28:08 +0000 Subject: [PATCH 4/6] add max string length --- src/flagsmith_schemas/constants.py | 1 + src/flagsmith_schemas/validators.py | 12 +++++++++- .../flagsmith_schemas/test_types.py | 22 ++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/flagsmith_schemas/constants.py b/src/flagsmith_schemas/constants.py index 08d62c3..f5795c5 100644 --- a/src/flagsmith_schemas/constants.py +++ b/src/flagsmith_schemas/constants.py @@ -1,3 +1,4 @@ from importlib.util import find_spec PYDANTIC_INSTALLED = find_spec("pydantic") is not None +MAX_STRING_FEARTURE_STATE_VALUE_LENGTH = 20_000 diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index c3f1eac..22b315a 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -1,6 +1,8 @@ import typing from decimal import Decimal +from flagsmith_schemas.constants import MAX_STRING_FEARTURE_STATE_VALUE_LENGTH + if typing.TYPE_CHECKING: from flagsmith_schemas.dynamodb import FeatureState, MultivariateFeatureStateValue from flagsmith_schemas.types import DynamoFeatureValue @@ -9,7 +11,15 @@ def validate_dynamo_feature_state_value( value: typing.Any, ) -> "DynamoFeatureValue": - if isinstance(value, bool | str | None): + if isinstance(value, bool | None): + return value + if isinstance(value, str): + if len(value) > MAX_STRING_FEARTURE_STATE_VALUE_LENGTH: + raise ValueError( + "Dynamo feature state value string length cannot exceed " + f"{MAX_STRING_FEARTURE_STATE_VALUE_LENGTH} characters " + f"(got {len(value)} characters)." + ) return value if isinstance(value, int): return Decimal(value) diff --git a/tests/integration/flagsmith_schemas/test_types.py b/tests/integration/flagsmith_schemas/test_types.py index 73ccd4a..9aa4fb2 100644 --- a/tests/integration/flagsmith_schemas/test_types.py +++ b/tests/integration/flagsmith_schemas/test_types.py @@ -1,4 +1,5 @@ -from pydantic import TypeAdapter +import pytest +from pydantic import TypeAdapter, ValidationError from flagsmith_schemas.types import DynamoFeatureValue @@ -12,3 +13,22 @@ def test_dynamo_feature_value__not_int__coerces_to_str() -> None: # Then assert result == "12.34" + + +def test_dynamo_feature_value__long_string__raises_expected() -> None: + # Given + type_adapter: TypeAdapter[DynamoFeatureValue] = TypeAdapter(DynamoFeatureValue) + + # When + with pytest.raises(ValidationError) as exc_info: + type_adapter.validate_python("a" * 20_001) + + # Then + assert len(exc_info.value.errors()) == 1 + assert ( + exc_info.value.errors()[0].items() + >= { + "type": "value_error", + "msg": "Value error, Dynamo feature state value string length cannot exceed 20000 characters (got 20001 characters).", + }.items() + ) From dd72418a6c3462102b3a2530185a49d5ca965618 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 5 Jan 2026 17:44:45 +0000 Subject: [PATCH 5/6] fix typo --- src/flagsmith_schemas/constants.py | 2 +- src/flagsmith_schemas/validators.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/flagsmith_schemas/constants.py b/src/flagsmith_schemas/constants.py index f5795c5..6396d15 100644 --- a/src/flagsmith_schemas/constants.py +++ b/src/flagsmith_schemas/constants.py @@ -1,4 +1,4 @@ from importlib.util import find_spec PYDANTIC_INSTALLED = find_spec("pydantic") is not None -MAX_STRING_FEARTURE_STATE_VALUE_LENGTH = 20_000 +MAX_STRING_FEATURE_STATE_VALUE_LENGTH = 20_000 diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index 22b315a..505c2c4 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -1,7 +1,7 @@ import typing from decimal import Decimal -from flagsmith_schemas.constants import MAX_STRING_FEARTURE_STATE_VALUE_LENGTH +from flagsmith_schemas.constants import MAX_STRING_FEATURE_STATE_VALUE_LENGTH if typing.TYPE_CHECKING: from flagsmith_schemas.dynamodb import FeatureState, MultivariateFeatureStateValue @@ -14,10 +14,10 @@ def validate_dynamo_feature_state_value( if isinstance(value, bool | None): return value if isinstance(value, str): - if len(value) > MAX_STRING_FEARTURE_STATE_VALUE_LENGTH: + if len(value) > MAX_STRING_FEATURE_STATE_VALUE_LENGTH: raise ValueError( "Dynamo feature state value string length cannot exceed " - f"{MAX_STRING_FEARTURE_STATE_VALUE_LENGTH} characters " + f"{MAX_STRING_FEATURE_STATE_VALUE_LENGTH} characters " f"(got {len(value)} characters)." ) return value From b08502ffde9abd3cd2bbb8f40a98e0a9974272bd Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 5 Jan 2026 18:11:46 +0000 Subject: [PATCH 6/6] O(n) `validate_identity_feature_states` --- src/flagsmith_schemas/validators.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index 505c2c4..a43b519 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -41,12 +41,15 @@ def validate_multivariate_feature_state_values( def validate_identity_feature_states( values: "list[FeatureState]", ) -> "list[FeatureState]": - for i, feature_state in enumerate(values, start=1): - if feature_state["feature"]["id"] in [ - feature_state["feature"]["id"] for feature_state in values[i:] - ]: + seen: set[Decimal] = set() + + for feature_state in values: + feature_id = feature_state["feature"]["id"] + if feature_id in seen: raise ValueError( - f"Feature id={feature_state['feature']['id']} cannot have multiple " + f"Feature id={feature_id} cannot have multiple " "feature states for a single identity." ) + seen.add(feature_id) + return values