From eb7095a7a15731be065ea8172fba8e8b3b9e8436 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 06:00:00 +0000 Subject: [PATCH 1/5] fix(types): update SDKTraitData to accept raw trait values The trait_value field in SDKTraitData now correctly accepts both: - Structured SDKTraitValueData (from serializer validation via TraitValueField) - Raw primitive values (str, int, bool, float) when called directly - None (to delete traits) This aligns the type definition with the actual runtime behavior of update_traits() and generate_traits() methods which handle both cases via Trait.generate_trait_value_data(). --- api/environments/sdk/types.py | 9 ++++++++- api/tests/unit/environments/identities/helpers.py | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/api/environments/sdk/types.py b/api/environments/sdk/types.py index 0cb29a3df9c1..1184ef61f9a7 100644 --- a/api/environments/sdk/types.py +++ b/api/environments/sdk/types.py @@ -8,7 +8,14 @@ class SDKTraitValueData(typing.TypedDict): value: str +# Trait value can be either: +# 1. A structured SDKTraitValueData (from serializer validation via TraitValueField) +# 2. A raw primitive value (str, int, bool, float) when called directly +# 3. None (to delete the trait) +TraitValue: typing.TypeAlias = SDKTraitValueData | str | int | bool | float | None + + class SDKTraitData(typing.TypedDict): trait_key: str - trait_value: SDKTraitValueData | None + trait_value: TraitValue transient: NotRequired[bool] diff --git a/api/tests/unit/environments/identities/helpers.py b/api/tests/unit/environments/identities/helpers.py index 6d9d1cd0625f..23506fdd98aa 100644 --- a/api/tests/unit/environments/identities/helpers.py +++ b/api/tests/unit/environments/identities/helpers.py @@ -2,13 +2,14 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait +from environments.sdk.types import SDKTraitData, TraitValue -def generate_trait_data_item( # type: ignore[no-untyped-def] +def generate_trait_data_item( trait_key: str = "trait_key", - trait_value: typing.Any = "trait_value", + trait_value: TraitValue = "trait_value", transient: bool = False, -): +) -> SDKTraitData: return { "trait_key": trait_key, "trait_value": trait_value, From b9a32c5a34cea1b10b3a1576aec95070202de934 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 06:49:47 +0000 Subject: [PATCH 2/5] fix(types): correct SDKTraitValueData.value to accept all primitive types The value field can be str, int, bool, or float based on the trait's actual value type, not just str. --- api/environments/sdk/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/environments/sdk/types.py b/api/environments/sdk/types.py index 1184ef61f9a7..e74606766497 100644 --- a/api/environments/sdk/types.py +++ b/api/environments/sdk/types.py @@ -5,7 +5,7 @@ class SDKTraitValueData(typing.TypedDict): type: str - value: str + value: str | int | bool | float # Trait value can be either: From 41d33b3843c5eacbde6197e70cca80f23e9ad8df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 06:58:53 +0000 Subject: [PATCH 3/5] fix(types): handle TraitValue union type correctly - Remove unused type: ignore comments in launch_darkly/services.py now that SDKTraitData accepts raw trait values - Update get_transient_identifier to handle both SDKTraitValueData and raw primitive values with proper type narrowing --- api/environments/sdk/services.py | 15 +++++++++++++-- api/integrations/launch_darkly/services.py | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/api/environments/sdk/services.py b/api/environments/sdk/services.py index c7341e93e187..4b9a193b74cb 100644 --- a/api/environments/sdk/services.py +++ b/api/environments/sdk/services.py @@ -9,7 +9,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment -from environments.sdk.types import SDKTraitData +from environments.sdk.types import SDKTraitData, TraitValue IdentityAndTraits: TypeAlias = tuple[Identity, list[Trait]] @@ -93,9 +93,20 @@ def get_persisted_identity_and_traits( def get_transient_identifier(sdk_trait_data: list[SDKTraitData]) -> str: if sdk_trait_data: + + def get_value( + trait_value: TraitValue, + ) -> str | int | bool | float: + # Handle both structured SDKTraitValueData and raw primitive values + if isinstance(trait_value, dict): + return trait_value["value"] + # None is filtered out at the call site, but we need to satisfy the type checker + assert trait_value is not None + return trait_value + return hashlib.sha256( "".join( - f"{trait['trait_key']}{trait_value['value']}" + f"{trait['trait_key']}{get_value(trait_value)}" for trait in sorted(sdk_trait_data, key=itemgetter("trait_key")) if (trait_value := trait["trait_value"]) is not None ).encode(), diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index 84de934cdd2d..ab8db75bcd97 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -479,7 +479,7 @@ def _import_targets( [ { "trait_key": "key", - "trait_value": identifier, # type: ignore[typeddict-item] + "trait_value": identifier, } ] ) @@ -1029,7 +1029,7 @@ def _create_segments_from_ld( [ { "trait_key": "key", - "trait_value": identifier, # type: ignore[typeddict-item] + "trait_value": identifier, } ] ) From f942d95a82a15beb8c229769b165d76f4bcef138 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 07:25:41 +0000 Subject: [PATCH 4/5] test: add coverage for get_transient_identifier with TraitValue types Tests cover: - Structured SDKTraitValueData format (from serializer validation) - Raw primitive values (str, int, bool, float) - None values being skipped - Empty list returning UUID - Same value in structured vs raw format producing same hash --- .../sdk/test_unit_sdk_services.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 api/tests/unit/environments/sdk/test_unit_sdk_services.py diff --git a/api/tests/unit/environments/sdk/test_unit_sdk_services.py b/api/tests/unit/environments/sdk/test_unit_sdk_services.py new file mode 100644 index 000000000000..c6a9f187c254 --- /dev/null +++ b/api/tests/unit/environments/sdk/test_unit_sdk_services.py @@ -0,0 +1,123 @@ +from environments.sdk.services import get_transient_identifier +from environments.sdk.types import SDKTraitData + + +class TestGetTransientIdentifier: + def test_returns_consistent_hash_for_structured_trait_value(self) -> None: + """Test with SDKTraitValueData format (from serializer validation).""" + sdk_trait_data: list[SDKTraitData] = [ + { + "trait_key": "key1", + "trait_value": {"type": "str", "value": "value1"}, + } + ] + + result1 = get_transient_identifier(sdk_trait_data) + result2 = get_transient_identifier(sdk_trait_data) + + assert result1 == result2 + assert len(result1) == 64 # SHA256 hex digest length + + def test_returns_consistent_hash_for_raw_primitive_string(self) -> None: + """Test with raw string value (direct call without serializer).""" + sdk_trait_data: list[SDKTraitData] = [ + { + "trait_key": "key1", + "trait_value": "value1", + } + ] + + result1 = get_transient_identifier(sdk_trait_data) + result2 = get_transient_identifier(sdk_trait_data) + + assert result1 == result2 + assert len(result1) == 64 + + def test_returns_consistent_hash_for_raw_primitive_int(self) -> None: + """Test with raw int value.""" + sdk_trait_data: list[SDKTraitData] = [ + { + "trait_key": "key1", + "trait_value": 42, + } + ] + + result1 = get_transient_identifier(sdk_trait_data) + result2 = get_transient_identifier(sdk_trait_data) + + assert result1 == result2 + assert len(result1) == 64 + + def test_returns_consistent_hash_for_raw_primitive_bool(self) -> None: + """Test with raw bool value.""" + sdk_trait_data: list[SDKTraitData] = [ + { + "trait_key": "key1", + "trait_value": True, + } + ] + + result1 = get_transient_identifier(sdk_trait_data) + result2 = get_transient_identifier(sdk_trait_data) + + assert result1 == result2 + assert len(result1) == 64 + + def test_returns_consistent_hash_for_raw_primitive_float(self) -> None: + """Test with raw float value.""" + sdk_trait_data: list[SDKTraitData] = [ + { + "trait_key": "key1", + "trait_value": 3.14, + } + ] + + result1 = get_transient_identifier(sdk_trait_data) + result2 = get_transient_identifier(sdk_trait_data) + + assert result1 == result2 + assert len(result1) == 64 + + def test_returns_different_hash_for_different_values(self) -> None: + """Test that different values produce different hashes.""" + sdk_trait_data_1: list[SDKTraitData] = [ + {"trait_key": "key1", "trait_value": "value1"} + ] + sdk_trait_data_2: list[SDKTraitData] = [ + {"trait_key": "key1", "trait_value": "value2"} + ] + + result1 = get_transient_identifier(sdk_trait_data_1) + result2 = get_transient_identifier(sdk_trait_data_2) + + assert result1 != result2 + + def test_skips_none_trait_values(self) -> None: + """Test that None values are skipped.""" + sdk_trait_data: list[SDKTraitData] = [ + {"trait_key": "key1", "trait_value": "value1"}, + {"trait_key": "key2", "trait_value": None}, + ] + + # Should not raise and should only use non-None values + result = get_transient_identifier(sdk_trait_data) + assert len(result) == 64 + + def test_returns_uuid_for_empty_list(self) -> None: + """Test that empty list returns a UUID.""" + result = get_transient_identifier([]) + + # UUID hex is 32 characters + assert len(result) == 32 + + def test_structured_and_raw_same_value_produce_same_hash(self) -> None: + """Test that structured SDKTraitValueData and raw value with same content produce same hash.""" + structured: list[SDKTraitData] = [ + {"trait_key": "key1", "trait_value": {"type": "str", "value": "test"}} + ] + raw: list[SDKTraitData] = [{"trait_key": "key1", "trait_value": "test"}] + + result_structured = get_transient_identifier(structured) + result_raw = get_transient_identifier(raw) + + assert result_structured == result_raw From 1bfef76e3651d2ad918eea547691fda9718135cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 07:35:35 +0000 Subject: [PATCH 5/5] refactor: use type: ignore instead of helper function for get_transient_identifier The function only receives SDKTraitValueData from serializer paths, so a type: ignore comment is sufficient to handle the broadened TraitValue type. --- api/environments/sdk/services.py | 15 +-- .../sdk/test_unit_sdk_services.py | 123 ------------------ 2 files changed, 2 insertions(+), 136 deletions(-) delete mode 100644 api/tests/unit/environments/sdk/test_unit_sdk_services.py diff --git a/api/environments/sdk/services.py b/api/environments/sdk/services.py index 4b9a193b74cb..b80f1ee1ff76 100644 --- a/api/environments/sdk/services.py +++ b/api/environments/sdk/services.py @@ -9,7 +9,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment -from environments.sdk.types import SDKTraitData, TraitValue +from environments.sdk.types import SDKTraitData IdentityAndTraits: TypeAlias = tuple[Identity, list[Trait]] @@ -93,20 +93,9 @@ def get_persisted_identity_and_traits( def get_transient_identifier(sdk_trait_data: list[SDKTraitData]) -> str: if sdk_trait_data: - - def get_value( - trait_value: TraitValue, - ) -> str | int | bool | float: - # Handle both structured SDKTraitValueData and raw primitive values - if isinstance(trait_value, dict): - return trait_value["value"] - # None is filtered out at the call site, but we need to satisfy the type checker - assert trait_value is not None - return trait_value - return hashlib.sha256( "".join( - f"{trait['trait_key']}{get_value(trait_value)}" + f"{trait['trait_key']}{trait_value['value']}" # type: ignore[index] for trait in sorted(sdk_trait_data, key=itemgetter("trait_key")) if (trait_value := trait["trait_value"]) is not None ).encode(), diff --git a/api/tests/unit/environments/sdk/test_unit_sdk_services.py b/api/tests/unit/environments/sdk/test_unit_sdk_services.py deleted file mode 100644 index c6a9f187c254..000000000000 --- a/api/tests/unit/environments/sdk/test_unit_sdk_services.py +++ /dev/null @@ -1,123 +0,0 @@ -from environments.sdk.services import get_transient_identifier -from environments.sdk.types import SDKTraitData - - -class TestGetTransientIdentifier: - def test_returns_consistent_hash_for_structured_trait_value(self) -> None: - """Test with SDKTraitValueData format (from serializer validation).""" - sdk_trait_data: list[SDKTraitData] = [ - { - "trait_key": "key1", - "trait_value": {"type": "str", "value": "value1"}, - } - ] - - result1 = get_transient_identifier(sdk_trait_data) - result2 = get_transient_identifier(sdk_trait_data) - - assert result1 == result2 - assert len(result1) == 64 # SHA256 hex digest length - - def test_returns_consistent_hash_for_raw_primitive_string(self) -> None: - """Test with raw string value (direct call without serializer).""" - sdk_trait_data: list[SDKTraitData] = [ - { - "trait_key": "key1", - "trait_value": "value1", - } - ] - - result1 = get_transient_identifier(sdk_trait_data) - result2 = get_transient_identifier(sdk_trait_data) - - assert result1 == result2 - assert len(result1) == 64 - - def test_returns_consistent_hash_for_raw_primitive_int(self) -> None: - """Test with raw int value.""" - sdk_trait_data: list[SDKTraitData] = [ - { - "trait_key": "key1", - "trait_value": 42, - } - ] - - result1 = get_transient_identifier(sdk_trait_data) - result2 = get_transient_identifier(sdk_trait_data) - - assert result1 == result2 - assert len(result1) == 64 - - def test_returns_consistent_hash_for_raw_primitive_bool(self) -> None: - """Test with raw bool value.""" - sdk_trait_data: list[SDKTraitData] = [ - { - "trait_key": "key1", - "trait_value": True, - } - ] - - result1 = get_transient_identifier(sdk_trait_data) - result2 = get_transient_identifier(sdk_trait_data) - - assert result1 == result2 - assert len(result1) == 64 - - def test_returns_consistent_hash_for_raw_primitive_float(self) -> None: - """Test with raw float value.""" - sdk_trait_data: list[SDKTraitData] = [ - { - "trait_key": "key1", - "trait_value": 3.14, - } - ] - - result1 = get_transient_identifier(sdk_trait_data) - result2 = get_transient_identifier(sdk_trait_data) - - assert result1 == result2 - assert len(result1) == 64 - - def test_returns_different_hash_for_different_values(self) -> None: - """Test that different values produce different hashes.""" - sdk_trait_data_1: list[SDKTraitData] = [ - {"trait_key": "key1", "trait_value": "value1"} - ] - sdk_trait_data_2: list[SDKTraitData] = [ - {"trait_key": "key1", "trait_value": "value2"} - ] - - result1 = get_transient_identifier(sdk_trait_data_1) - result2 = get_transient_identifier(sdk_trait_data_2) - - assert result1 != result2 - - def test_skips_none_trait_values(self) -> None: - """Test that None values are skipped.""" - sdk_trait_data: list[SDKTraitData] = [ - {"trait_key": "key1", "trait_value": "value1"}, - {"trait_key": "key2", "trait_value": None}, - ] - - # Should not raise and should only use non-None values - result = get_transient_identifier(sdk_trait_data) - assert len(result) == 64 - - def test_returns_uuid_for_empty_list(self) -> None: - """Test that empty list returns a UUID.""" - result = get_transient_identifier([]) - - # UUID hex is 32 characters - assert len(result) == 32 - - def test_structured_and_raw_same_value_produce_same_hash(self) -> None: - """Test that structured SDKTraitValueData and raw value with same content produce same hash.""" - structured: list[SDKTraitData] = [ - {"trait_key": "key1", "trait_value": {"type": "str", "value": "test"}} - ] - raw: list[SDKTraitData] = [{"trait_key": "key1", "trait_value": "test"}] - - result_structured = get_transient_identifier(structured) - result_raw = get_transient_identifier(raw) - - assert result_structured == result_raw