From c68f7bc79c9687500307986df1223835aea10d45 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 26 Sep 2026 14:29:07 +0200 Subject: [PATCH 1/2] fix(schemas): keep stored Decimals when validating Dynamo documents --- src/flagsmith_schemas/pydantic_types.py | 25 +++++++- src/flagsmith_schemas/types.py | 8 ++- src/flagsmith_schemas/validators.py | 2 + .../flagsmith_schemas/test_dynamodb.py | 30 +++++++++ .../flagsmith_schemas/test_types.py | 61 ++++++++++++++++++- 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/flagsmith_schemas/pydantic_types.py b/src/flagsmith_schemas/pydantic_types.py index eae56560..0e993bf1 100644 --- a/src/flagsmith_schemas/pydantic_types.py +++ b/src/flagsmith_schemas/pydantic_types.py @@ -1,8 +1,16 @@ from datetime import datetime from decimal import Decimal +from functools import partial +from typing import Any from uuid import UUID -from pydantic import AfterValidator, BeforeValidator, ValidateAs +from pydantic import ( + AfterValidator, + BeforeValidator, + ValidateAs, + ValidatorFunctionWrapHandler, + WrapValidator, +) from flagsmith_schemas.validators import ( validate_dynamo_feature_state_value, @@ -10,8 +18,23 @@ validate_multivariate_feature_state_values, ) + +def _keep_stored_decimal( + value: Any, handler: ValidatorFunctionWrapHandler, *, integral: bool +) -> Decimal: + # DynamoDB reads numbers back as `Decimal`s: keep them as they are. + if isinstance(value, Decimal) and ( + not integral or value == value.to_integral_value() + ): + return value + validated: Decimal = handler(value) + return validated + + ValidateDecimalAsFloat = ValidateAs(float, lambda v: Decimal(str(v))) ValidateDecimalAsInt = ValidateAs(int, lambda v: Decimal(v)) +KeepStoredFloat = WrapValidator(partial(_keep_stored_decimal, integral=False)) +KeepStoredInt = WrapValidator(partial(_keep_stored_decimal, integral=True)) ValidateStrAsISODateTime = ValidateAs(datetime, lambda dt: dt.isoformat()) ValidateStrAsUUID = ValidateAs(UUID, str) diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index 87cc7dbe..26f96114 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -22,6 +22,8 @@ from pydantic_core import core_schema from flagsmith_schemas.pydantic_types import ( + KeepStoredFloat, + KeepStoredInt, ValidateDecimalAsFloat, ValidateDecimalAsInt, ValidateDynamoFeatureStateValue, @@ -36,6 +38,8 @@ def WithJsonSchema(_: object) -> object: return ... + KeepStoredFloat = ... + KeepStoredInt = ... ValidateDecimalAsFloat = ... ValidateDecimalAsInt = ... ValidateDynamoFeatureStateValue = ... @@ -76,14 +80,14 @@ def _validate_json_gzipped(data: Any) -> bytes: ) -DynamoInt: TypeAlias = Annotated[Decimal, ValidateDecimalAsInt] +DynamoInt: TypeAlias = Annotated[Decimal, ValidateDecimalAsInt, KeepStoredInt] """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, KeepStoredFloat] """A float value stored in DynamoDB. DynamoDB represents all numbers as `Decimal`. diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index a43b5194..d768f1db 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -23,6 +23,8 @@ def validate_dynamo_feature_state_value( return value if isinstance(value, int): return Decimal(value) + if isinstance(value, Decimal) and value == value.to_integral_value(): + return value return str(value) diff --git a/tests/integration/flagsmith_schemas/test_dynamodb.py b/tests/integration/flagsmith_schemas/test_dynamodb.py index 9f9c265d..78ed1675 100644 --- a/tests/integration/flagsmith_schemas/test_dynamodb.py +++ b/tests/integration/flagsmith_schemas/test_dynamodb.py @@ -970,6 +970,36 @@ def test_document__validate_json__expected_result( assert document == expected_result +@pytest.mark.parametrize( + ("document_type", "json_data_filename"), + [ + (Environment, "flagsmith_environments.json"), + (EnvironmentAPIKey, "flagsmith_environment_api_key.json"), + (Identity, "flagsmith_identities.json"), + (EnvironmentV2Meta, "flagsmith_environments_v2:_META.json"), + ( + EnvironmentV2IdentityOverride, + "flagsmith_environments_v2:identity_override.json", + ), + ], +) +def test_document__validate_python_stored_document__returns_unchanged( + request: pytest.FixtureRequest, + document_type: type[T], + json_data_filename: str, +) -> None: + # Given + type_adapter = TypeAdapter(document_type) + json_data = request.path.parent.joinpath(f"data/{json_data_filename}").read_text() + stored_document = type_adapter.validate_json(json_data) + + # When + document = type_adapter.validate_python(stored_document) + + # Then + assert document == stored_document + + def test_type_adapter__identity_duplicate_features__raises_expected( mocker: MockerFixture, ) -> None: diff --git a/tests/integration/flagsmith_schemas/test_types.py b/tests/integration/flagsmith_schemas/test_types.py index 29e8bd26..42870fd6 100644 --- a/tests/integration/flagsmith_schemas/test_types.py +++ b/tests/integration/flagsmith_schemas/test_types.py @@ -1,9 +1,15 @@ import gzip +from decimal import Decimal import pytest from pydantic import TypeAdapter, ValidationError -from flagsmith_schemas.types import DynamoFeatureValue, JsonGzipped +from flagsmith_schemas.types import ( + DynamoContextValue, + DynamoFeatureValue, + DynamoInt, + JsonGzipped, +) def test_dynamo_feature_value__not_int__coerces_to_str() -> None: @@ -17,6 +23,59 @@ def test_dynamo_feature_value__not_int__coerces_to_str() -> None: assert result == "12.34" +@pytest.mark.parametrize( + ("value", "expected_result"), + [ + pytest.param(Decimal("1234"), Decimal("1234"), id="integer"), + pytest.param(Decimal("12.34"), "12.34", id="non_integer"), + ], +) +def test_dynamo_feature_value__stored_decimal__returns_expected( + value: Decimal, + expected_result: DynamoFeatureValue, +) -> None: + # Given + type_adapter: TypeAdapter[DynamoFeatureValue] = TypeAdapter(DynamoFeatureValue) + + # When + result = type_adapter.validate_python(value) + + # Then + assert result == expected_result + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(Decimal("42"), id="integer"), + pytest.param(Decimal("1.5"), id="non_integer"), + ], +) +def test_dynamo_context_value__stored_decimal__returns_unchanged( + value: Decimal, +) -> None: + # Given + type_adapter: TypeAdapter[DynamoContextValue] = TypeAdapter(DynamoContextValue) + + # When + result = type_adapter.validate_python(value) + + # Then + assert str(result) == str(value) + + +def test_dynamo_int__stored_non_integer_decimal__raises_expected() -> None: + # Given + type_adapter: TypeAdapter[DynamoInt] = TypeAdapter(DynamoInt) + + # When + with pytest.raises(ValidationError) as exc_info: + type_adapter.validate_python(Decimal("1.5")) + + # Then + assert exc_info.value.errors()[0]["type"] == "int_from_float" + + def test_dynamo_feature_value__long_string__raises_expected() -> None: # Given type_adapter: TypeAdapter[DynamoFeatureValue] = TypeAdapter(DynamoFeatureValue) From 8aa120be0c70e70e260dc9fddd6dd9df267d76dc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 26 Sep 2026 18:39:23 +0200 Subject: [PATCH 2/2] fix(schemas): keep only finite integral Decimals as integers --- src/flagsmith_schemas/pydantic_types.py | 5 ++--- src/flagsmith_schemas/validators.py | 10 +++++++++- .../flagsmith_schemas/test_types.py | 18 +++++++++++++++--- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/flagsmith_schemas/pydantic_types.py b/src/flagsmith_schemas/pydantic_types.py index 0e993bf1..97621032 100644 --- a/src/flagsmith_schemas/pydantic_types.py +++ b/src/flagsmith_schemas/pydantic_types.py @@ -13,6 +13,7 @@ ) from flagsmith_schemas.validators import ( + is_integral_decimal, validate_dynamo_feature_state_value, validate_identity_feature_states, validate_multivariate_feature_state_values, @@ -23,9 +24,7 @@ def _keep_stored_decimal( value: Any, handler: ValidatorFunctionWrapHandler, *, integral: bool ) -> Decimal: # DynamoDB reads numbers back as `Decimal`s: keep them as they are. - if isinstance(value, Decimal) and ( - not integral or value == value.to_integral_value() - ): + if isinstance(value, Decimal) and (not integral or is_integral_decimal(value)): return value validated: Decimal = handler(value) return validated diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index d768f1db..ed7c0fed 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -8,6 +8,14 @@ from flagsmith_schemas.types import DynamoFeatureValue +def is_integral_decimal(value: typing.Any) -> typing.TypeGuard[Decimal]: + return ( + isinstance(value, Decimal) + and value.is_finite() + and value == value.to_integral_value() + ) + + def validate_dynamo_feature_state_value( value: typing.Any, ) -> "DynamoFeatureValue": @@ -23,7 +31,7 @@ def validate_dynamo_feature_state_value( return value if isinstance(value, int): return Decimal(value) - if isinstance(value, Decimal) and value == value.to_integral_value(): + if is_integral_decimal(value): return value return str(value) diff --git a/tests/integration/flagsmith_schemas/test_types.py b/tests/integration/flagsmith_schemas/test_types.py index 42870fd6..b866509f 100644 --- a/tests/integration/flagsmith_schemas/test_types.py +++ b/tests/integration/flagsmith_schemas/test_types.py @@ -28,6 +28,7 @@ def test_dynamo_feature_value__not_int__coerces_to_str() -> None: [ pytest.param(Decimal("1234"), Decimal("1234"), id="integer"), pytest.param(Decimal("12.34"), "12.34", id="non_integer"), + pytest.param(Decimal("Infinity"), "Infinity", id="infinity"), ], ) def test_dynamo_feature_value__stored_decimal__returns_expected( @@ -61,19 +62,30 @@ def test_dynamo_context_value__stored_decimal__returns_unchanged( result = type_adapter.validate_python(value) # Then + assert isinstance(result, Decimal) assert str(result) == str(value) -def test_dynamo_int__stored_non_integer_decimal__raises_expected() -> None: +@pytest.mark.parametrize( + ("value", "expected_error_type"), + [ + pytest.param(Decimal("1.5"), "int_from_float", id="non_integer"), + pytest.param(Decimal("Infinity"), "finite_number", id="infinity"), + ], +) +def test_dynamo_int__stored_non_integer_decimal__raises_expected( + value: Decimal, + expected_error_type: str, +) -> None: # Given type_adapter: TypeAdapter[DynamoInt] = TypeAdapter(DynamoInt) # When with pytest.raises(ValidationError) as exc_info: - type_adapter.validate_python(Decimal("1.5")) + type_adapter.validate_python(value) # Then - assert exc_info.value.errors()[0]["type"] == "int_from_float" + assert exc_info.value.errors()[0]["type"] == expected_error_type def test_dynamo_feature_value__long_string__raises_expected() -> None: