diff --git a/pyproject.toml b/pyproject.toml index 13b854f..44e1fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,9 @@ optional-dependencies = { test-tools = [ "django-health-check", "prometheus-client (>=0.0.16)", ], flagsmith-schemas = [ + "simplejson", "typing_extensions", - "flagsmith-flag-engine>10", + "flagsmith-flag-engine>6", ] } authors = [ { name = "Matthew Elwell" }, diff --git a/src/flagsmith_schemas/dynamodb.py b/src/flagsmith_schemas/dynamodb.py index 7d69594..ed9c3ae 100644 --- a/src/flagsmith_schemas/dynamodb.py +++ b/src/flagsmith_schemas/dynamodb.py @@ -20,6 +20,7 @@ DynamoFloat, DynamoInt, FeatureType, + JsonGzipped, UUIDStr, ) @@ -200,7 +201,7 @@ class Webhook(TypedDict): """Secret used to sign webhook payloads.""" -class _EnvironmentFields(TypedDict): +class _EnvironmentBaseFields(TypedDict): """Common fields for Environment documents.""" name: NotRequired[str] @@ -208,11 +209,6 @@ class _EnvironmentFields(TypedDict): updated_at: NotRequired[DateTimeStr | None] """Last updated timestamp. If not set, current timestamp should be assumed.""" - project: Project - """Project-specific data for this environment.""" - feature_states: list[FeatureState] - """List of feature states representing the environment defaults.""" - allow_client_traits: NotRequired[bool] """Whether the SDK API should allow clients to set traits for this environment. Identical to project-level's `persist_trait_data` setting. Defaults to `True`.""" hide_sensitive_data: NotRequired[bool] @@ -240,7 +236,52 @@ class _EnvironmentFields(TypedDict): """Webhook configuration.""" -### Root document schemas below. Indexed fields are marked as **INDEXED** in the docstrings. ### +class _EnvironmentV1Fields(TypedDict): + """Common fields for environment documents in `flagsmith_environments`.""" + + api_key: str + """Public client-side API key for the environment. **INDEXED**.""" + id: DynamoInt + """Unique identifier for the environment in Core.""" + + +class _EnvironmentV2MetaFields(TypedDict): + """Common fields for environment documents in `flagsmith_environments_v2`.""" + + environment_id: str + """Unique identifier for the environment in Core. Same as `Environment.id`, but string-typed to reduce coupling with Core's type definitions **INDEXED**.""" + environment_api_key: str + """Public client-side API key for the environment. **INDEXED**.""" + document_key: Literal["_META"] + """The fixed document key for the environment v2 document. Always `"_META"`. **INDEXED**.""" + + id: DynamoInt + """Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema.""" + + +class _EnvironmentBaseFieldsUncompressed(TypedDict): + """Common fields for uncompressed environment documents.""" + + project: Project + """Project-specific data for this environment.""" + feature_states: list[FeatureState] + """List of feature states representing the environment defaults.""" + compressed: NotRequired[Literal[False]] + """Either `False` or absent to indicate the data is uncompressed.""" + + +class _EnvironmentBaseFieldsCompressed(TypedDict): + """Common fields for compressed environment documents.""" + + project: JsonGzipped[Project] + """Project-specific data for this environment. **COMPRESSED**.""" + feature_states: JsonGzipped[list[FeatureState]] + """List of feature states representing the environment defaults. **COMPRESSED**.""" + compressed: Literal[True] + """Always `True` to indicate the data is compressed.""" + + +### Root document schemas below. Indexed fields are marked as **INDEXED** in the docstrings. Compressed fields are marked as **COMPRESSED**. ### class EnvironmentAPIKey(TypedDict): @@ -295,33 +336,50 @@ class Identity(TypedDict): """Unique identifier for the identity in Core. If identity created via Core's `edge-identities` API, this can be missing or `None`.""" -class Environment(_EnvironmentFields): +class Environment( + _EnvironmentBaseFieldsUncompressed, + _EnvironmentV1Fields, + _EnvironmentBaseFields, +): """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. **DynamoDB table**: `flagsmith_environments` """ - api_key: str - """Public client-side API key for the environment. **INDEXED**.""" - id: DynamoInt - """Unique identifier for the environment in Core.""" +class EnvironmentCompressed( + _EnvironmentBaseFieldsCompressed, + _EnvironmentV1Fields, + _EnvironmentBaseFields, +): + """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. + Has compressed fields. + + **DynamoDB table**: `flagsmith_environments` + """ -class EnvironmentV2Meta(_EnvironmentFields): + +class EnvironmentV2Meta( + _EnvironmentBaseFieldsUncompressed, + _EnvironmentV2MetaFields, + _EnvironmentBaseFields, +): """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. **DynamoDB table**: `flagsmith_environments_v2` """ - environment_id: str - """Unique identifier for the environment in Core. Same as `Environment.id`, but string-typed to reduce coupling with Core's type definitions **INDEXED**.""" - environment_api_key: str - """Public client-side API key for the environment. **INDEXED**.""" - document_key: Literal["_META"] - """The fixed document key for the environment v2 document. Always `"_META"`. **INDEXED**.""" - id: DynamoInt - """Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema.""" +class EnvironmentV2MetaCompressed( + _EnvironmentBaseFieldsCompressed, + _EnvironmentV2MetaFields, + _EnvironmentBaseFields, +): + """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. + Has compressed fields. + + **DynamoDB table**: `flagsmith_environments_v2` + """ class EnvironmentV2IdentityOverride(TypedDict): diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index 73b3b35..87cc7db 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -1,10 +1,25 @@ from decimal import Decimal -from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + SupportsBytes, + TypeAlias, + TypeVar, + get_args, +) from flagsmith_schemas.constants import PYDANTIC_INSTALLED if PYDANTIC_INSTALLED: - from pydantic import WithJsonSchema + from pydantic import ( + GetCoreSchemaHandler, + TypeAdapter, + WithJsonSchema, + ) + from pydantic_core import core_schema from flagsmith_schemas.pydantic_types import ( ValidateDecimalAsFloat, @@ -13,6 +28,7 @@ ValidateStrAsISODateTime, ValidateStrAsUUID, ) + from flagsmith_schemas.utils import json_gzip 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. @@ -26,6 +42,39 @@ def WithJsonSchema(_: object) -> object: ValidateStrAsISODateTime = ... ValidateStrAsUUID = ... +T = TypeVar("T") + + +class DynamoBinary(SupportsBytes): + """boto3's wrapper type for bytes stored in DynamoDB.""" + + value: bytes | bytearray + + +class JsonGzipped(DynamoBinary, Generic[T]): + """A gzipped JSON blob representing a value of type `T`.""" + + if PYDANTIC_INSTALLED: + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: "type[JsonGzipped[T]]", + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + _adapter: TypeAdapter[T] = TypeAdapter(get_args(source_type)[0]) + + def _validate_json_gzipped(data: Any) -> bytes: + return json_gzip(_adapter.validate_python(data)) + + # We're returning bytes here for two reasons: + # 1. boto3.dynamodb seems to expect bytes as input for Binary columns. + # 2. We want to avoid having boto3 as a dependency. + return core_schema.no_info_before_validator_function( + _validate_json_gzipped, + core_schema.bytes_schema(strict=False), + ) + DynamoInt: TypeAlias = Annotated[Decimal, ValidateDecimalAsInt] """An integer value stored in DynamoDB. diff --git a/src/flagsmith_schemas/utils.py b/src/flagsmith_schemas/utils.py new file mode 100644 index 0000000..d237e71 --- /dev/null +++ b/src/flagsmith_schemas/utils.py @@ -0,0 +1,15 @@ +import gzip +import typing + +import simplejson as json + + +def json_gzip(value: typing.Any) -> bytes: + return gzip.compress( + json.dumps( + value, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + mtime=0, + ) diff --git a/tests/integration/flagsmith_schemas/data/flagsmith_environments.json b/tests/integration/flagsmith_schemas/data/flagsmith_environments.json index 999fb06..da5f60c 100644 --- a/tests/integration/flagsmith_schemas/data/flagsmith_environments.json +++ b/tests/integration/flagsmith_schemas/data/flagsmith_environments.json @@ -257,28 +257,28 @@ "multivariate_feature_option": { "value": "baz" }, - "percentage_allocation": 30 + "percentage_allocation": 30.0 }, { "id": 3402, "multivariate_feature_option": { "value": "bar" }, - "percentage_allocation": 30 + "percentage_allocation": 30.0 }, { "id": 3405, "multivariate_feature_option": { "value": 1 }, - "percentage_allocation": 0 + "percentage_allocation": 0.0 }, { "id": 3406, "multivariate_feature_option": { "value": true }, - "percentage_allocation": 0 + "percentage_allocation": 0.0 } ], "django_id": 78986, diff --git a/tests/integration/flagsmith_schemas/data/flagsmith_environments_compressed.json b/tests/integration/flagsmith_schemas/data/flagsmith_environments_compressed.json new file mode 100644 index 0000000..10abc32 --- /dev/null +++ b/tests/integration/flagsmith_schemas/data/flagsmith_environments_compressed.json @@ -0,0 +1,295 @@ +{ + "compressed": true, + "project": { + "hide_disabled_flags": false, + "segments": [ + { + "name": "regular_segment", + "feature_states": [ + { + "feature_state_value": "segment_override", + "multivariate_feature_state_values": [], + "django_id": 81027, + "feature": { + "id": 15058, + "type": "STANDARD", + "name": "string_feature" + }, + "enabled": false + } + ], + "id": 4267, + "rules": [ + { + "type": "ALL", + "conditions": [], + "rules": [ + { + "type": "ANY", + "conditions": [ + { + "value": "40", + "property_": "age", + "operator": "LESS_THAN" + } + ], + "rules": [] + }, + { + "type": "ANY", + "conditions": [ + { + "value": "21", + "property_": "age", + "operator": "GREATER_THAN_INCLUSIVE" + } + ], + "rules": [] + }, + { + "type": "ANY", + "conditions": [ + { + "value": "green", + "property_": "favourite_colour", + "operator": "EQUAL" + }, + { + "value": "blue", + "property_": "favourite_colour", + "operator": "EQUAL" + } + ], + "rules": [] + } + ] + } + ] + }, + { + "name": "10_percent", + "feature_states": [ + { + "feature_state_value": "", + "multivariate_feature_state_values": [], + "django_id": 81026, + "feature": { + "id": 15060, + "type": "STANDARD", + "name": "basic_flag" + }, + "enabled": true + } + ], + "id": 4268, + "rules": [ + { + "type": "ALL", + "conditions": [], + "rules": [ + { + "type": "ANY", + "conditions": [ + { + "value": "0.1", + "property_": "", + "operator": "PERCENTAGE_SPLIT" + } + ], + "rules": [] + } + ] + } + ] + }, + { + "feature_states": [ + { + "feature_state_value": "segment_two_override_priority_0", + "multivariate_feature_state_values": [], + "django_id": 78978, + "feature": { + "id": 15058, + "type": "STANDARD", + "name": "string_feature" + }, + "enabled": true, + "feature_segment": { + "priority": 0 + }, + "featurestate_uuid": "1545809c-e97f-4a1f-9e67-8b4f2b396aa6" + } + ], + "id": 16, + "name": "segment_two", + "rules": [ + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "operator": "EQUAL", + "property_": "two", + "value": "2" + }, + { + "operator": "IS_SET", + "property_": "two", + "value": null + } + ], + "rules": [], + "type": "ANY" + } + ], + "type": "ALL" + } + ] + }, + { + "feature_states": [ + { + "feature_state_value": "segment_three_override_priority_1", + "multivariate_feature_state_values": [], + "django_id": 78977, + "feature": { + "id": 15058, + "type": "STANDARD", + "name": "string_feature" + }, + "enabled": true, + "feature_segment": { + "priority": 1 + }, + "featurestate_uuid": "1545809c-e97f-4a1f-9e67-8b4f2b396aa7" + } + ], + "id": 17, + "name": "segment_three", + "rules": [ + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "operator": "EQUAL", + "property_": "three", + "value": "3" + }, + { + "operator": "IS_NOT_SET", + "property_": "something_that_is_not_set", + "value": null + } + ], + "rules": [], + "type": "ALL" + } + ], + "type": "ALL" + } + ] + } + ], + "name": "Edge API Test Project", + "id": 5359, + "organisation": { + "persist_trait_data": true, + "name": "Flagsmith", + "feature_analytics": false, + "stop_serving_flags": false, + "id": 13 + } + }, + "api_key": "n9fbf9h3v4fFgH3U3ngWhb", + "feature_states": [ + { + "feature_state_value": "foo", + "multivariate_feature_state_values": [], + "feature_segment": null, + "django_id": 78978, + "feature": { + "id": 15058, + "type": "STANDARD", + "name": "string_feature" + }, + "enabled": true + }, + { + "feature_state_value": 1234, + "multivariate_feature_state_values": [], + "django_id": 78980, + "feature": { + "id": 15059, + "type": "STANDARD", + "name": "integer_feature" + }, + "enabled": true + }, + { + "feature_state_value": null, + "multivariate_feature_state_values": [], + "django_id": 78982, + "feature": { + "id": 15060, + "type": "STANDARD", + "name": "basic_flag" + }, + "enabled": false + }, + { + "feature_state_value": "12.34", + "multivariate_feature_state_values": [], + "django_id": 78984, + "feature": { + "id": 15061, + "type": "STANDARD", + "name": "float_feature" + }, + "enabled": true + }, + { + "feature_state_value": "foo", + "multivariate_feature_state_values": [ + { + "id": 3404, + "multivariate_feature_option": { + "value": "baz" + }, + "percentage_allocation": 30.0 + }, + { + "id": 3402, + "multivariate_feature_option": { + "value": "bar" + }, + "percentage_allocation": 30.0 + }, + { + "id": 3405, + "multivariate_feature_option": { + "value": 1 + }, + "percentage_allocation": 0.0 + }, + { + "id": 3406, + "multivariate_feature_option": { + "value": true + }, + "percentage_allocation": 0.0 + } + ], + "django_id": 78986, + "feature": { + "id": 15062, + "type": "MULTIVARIATE", + "name": "mv_feature" + }, + "enabled": true + } + ], + "id": 12561 +} \ No newline at end of file diff --git a/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META.json b/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META.json index acecd2a..127848e 100644 --- a/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META.json +++ b/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META.json @@ -25,7 +25,7 @@ "value": "second" }, "mv_fs_value_uuid": "0b02ce41-9965-4c61-8b96-c8d76e3d4a27", - "percentage_allocation": 10 + "percentage_allocation": 10.0 }, { "id": 48717, @@ -34,7 +34,7 @@ "value": true }, "mv_fs_value_uuid": "cb05f49c-de1f-44f1-87eb-c3b55d473063", - "percentage_allocation": 30 + "percentage_allocation": 30.0 } ] }, diff --git a/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META_compressed.json b/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META_compressed.json new file mode 100644 index 0000000..b180db5 --- /dev/null +++ b/tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META_compressed.json @@ -0,0 +1,109 @@ +{ + "compressed": true, + "environment_id": "49268", + "document_key": "_META", + "allow_client_traits": true, + "amplitude_config": null, + "dynatrace_config": null, + "environment_api_key": "AQ9T6LixPqYMJkuqGJy3t2", + "feature_states": [ + { + "django_id": 577621, + "enabled": true, + "feature": { + "id": 100298, + "name": "test_feature", + "type": "MULTIVARIATE" + }, + "featurestate_uuid": "42d7805e-a9ac-401c-a7b7-d6583ac5a365", + "feature_segment": null, + "feature_state_value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "multivariate_feature_state_values": [ + { + "id": 185130, + "multivariate_feature_option": { + "id": 20919, + "value": "second" + }, + "mv_fs_value_uuid": "0b02ce41-9965-4c61-8b96-c8d76e3d4a27", + "percentage_allocation": 10.0 + }, + { + "id": 48717, + "multivariate_feature_option": { + "id": 14004, + "value": true + }, + "mv_fs_value_uuid": "cb05f49c-de1f-44f1-87eb-c3b55d473063", + "percentage_allocation": 30.0 + } + ] + }, + { + "django_id": 1041292, + "enabled": false, + "feature": { + "id": 172422, + "name": "feature", + "type": "STANDARD" + }, + "featurestate_uuid": "58b7b954-1b75-493a-82df-5be0efeedd2a", + "feature_segment": null, + "feature_state_value": 3, + "multivariate_feature_state_values": [] + } + ], + "heap_config": null, + "hide_disabled_flags": null, + "hide_sensitive_data": false, + "id": 49268, + "identity_overrides": [], + "mixpanel_config": null, + "name": "Development", + "project": { + "enable_realtime_updates": false, + "hide_disabled_flags": false, + "id": 19368, + "name": "Example Project", + "organisation": { + "feature_analytics": false, + "id": 13, + "name": "Flagsmith", + "persist_trait_data": true, + "stop_serving_flags": false + }, + "segments": [ + { + "feature_states": [], + "id": 44126, + "name": "test", + "rules": [ + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "operator": "EQUAL", + "property_": "test", + "value": "test" + } + ], + "rules": [], + "type": "ANY" + } + ], + "type": "ALL" + } + ] + } + ], + "server_key_only_feature_ids": [] + }, + "rudderstack_config": null, + "segment_config": null, + "updated_at": "2025-11-16T13:28:31.244331+00:00", + "use_identity_composite_key_for_hashing": true, + "use_identity_overrides_in_local_eval": false, + "webhook_config": null +} \ No newline at end of file diff --git a/tests/integration/flagsmith_schemas/test_dynamodb.py b/tests/integration/flagsmith_schemas/test_dynamodb.py index aec403c..f6f0581 100644 --- a/tests/integration/flagsmith_schemas/test_dynamodb.py +++ b/tests/integration/flagsmith_schemas/test_dynamodb.py @@ -1,17 +1,21 @@ +import gzip from decimal import Decimal from importlib import reload from sys import modules from typing import TypeVar import pytest +import simplejson as json from pydantic import TypeAdapter, ValidationError from pytest_mock import MockerFixture from flagsmith_schemas.dynamodb import ( Environment, EnvironmentAPIKey, + EnvironmentCompressed, EnvironmentV2IdentityOverride, EnvironmentV2Meta, + EnvironmentV2MetaCompressed, Identity, ) from flagsmith_schemas.types import DateTimeStr, UUIDStr @@ -313,6 +317,318 @@ }, id="flagsmith_environments", ), + pytest.param( + EnvironmentCompressed, + "flagsmith_environments_compressed.json", + { + "compressed": True, + "id": Decimal("12561"), + "api_key": "n9fbf9h3v4fFgH3U3ngWhb", + "project": gzip.compress( + json.dumps( + { + "id": Decimal("5359"), + "name": "Edge API Test Project", + "organisation": { + "id": Decimal("13"), + "name": "Flagsmith", + "feature_analytics": False, + "stop_serving_flags": False, + "persist_trait_data": True, + }, + "segments": [ + { + "id": Decimal("4267"), + "name": "regular_segment", + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "operator": "LESS_THAN", + "value": "40", + "property_": "age", + } + ], + }, + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "operator": "GREATER_THAN_INCLUSIVE", + "value": "21", + "property_": "age", + } + ], + }, + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "operator": "EQUAL", + "value": "green", + "property_": "favourite_colour", + }, + { + "operator": "EQUAL", + "value": "blue", + "property_": "favourite_colour", + }, + ], + }, + ], + "conditions": [], + } + ], + "feature_states": [ + { + "feature": { + "id": Decimal("15058"), + "name": "string_feature", + "type": "STANDARD", + }, + "enabled": False, + "feature_state_value": "segment_override", + "django_id": Decimal("81027"), + "multivariate_feature_state_values": [], + } + ], + }, + { + "id": Decimal("4268"), + "name": "10_percent", + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "operator": "PERCENTAGE_SPLIT", + "value": "0.1", + "property_": "", + } + ], + } + ], + "conditions": [], + } + ], + "feature_states": [ + { + "feature": { + "id": Decimal("15060"), + "name": "basic_flag", + "type": "STANDARD", + }, + "enabled": True, + "feature_state_value": "", + "django_id": Decimal("81026"), + "multivariate_feature_state_values": [], + } + ], + }, + { + "id": Decimal("16"), + "name": "segment_two", + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "operator": "EQUAL", + "value": "2", + "property_": "two", + }, + { + "operator": "IS_SET", + "value": None, + "property_": "two", + }, + ], + } + ], + "conditions": [], + } + ], + "feature_states": [ + { + "feature": { + "id": Decimal("15058"), + "name": "string_feature", + "type": "STANDARD", + }, + "enabled": True, + "feature_state_value": "segment_two_override_priority_0", + "django_id": Decimal("78978"), + "featurestate_uuid": UUIDStr( + "1545809c-e97f-4a1f-9e67-8b4f2b396aa6" + ), + "feature_segment": { + "priority": Decimal("0") + }, + "multivariate_feature_state_values": [], + } + ], + }, + { + "id": Decimal("17"), + "name": "segment_three", + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "operator": "EQUAL", + "value": "3", + "property_": "three", + }, + { + "operator": "IS_NOT_SET", + "value": None, + "property_": "something_that_is_not_set", + }, + ], + } + ], + "conditions": [], + } + ], + "feature_states": [ + { + "feature": { + "id": Decimal("15058"), + "name": "string_feature", + "type": "STANDARD", + }, + "enabled": True, + "feature_state_value": "segment_three_override_priority_1", + "django_id": Decimal("78977"), + "featurestate_uuid": UUIDStr( + "1545809c-e97f-4a1f-9e67-8b4f2b396aa7" + ), + "feature_segment": { + "priority": Decimal("1") + }, + "multivariate_feature_state_values": [], + } + ], + }, + ], + "hide_disabled_flags": False, + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + mtime=0, + ), + "feature_states": gzip.compress( + json.dumps( + [ + { + "feature": { + "id": Decimal("15058"), + "name": "string_feature", + "type": "STANDARD", + }, + "enabled": True, + "feature_state_value": "foo", + "django_id": Decimal("78978"), + "feature_segment": None, + "multivariate_feature_state_values": [], + }, + { + "feature": { + "id": Decimal("15059"), + "name": "integer_feature", + "type": "STANDARD", + }, + "enabled": True, + "feature_state_value": Decimal("1234"), + "django_id": Decimal("78980"), + "multivariate_feature_state_values": [], + }, + { + "feature": { + "id": Decimal("15060"), + "name": "basic_flag", + "type": "STANDARD", + }, + "enabled": False, + "feature_state_value": None, + "django_id": Decimal("78982"), + "multivariate_feature_state_values": [], + }, + { + "feature": { + "id": Decimal("15061"), + "name": "float_feature", + "type": "STANDARD", + }, + "enabled": True, + "feature_state_value": "12.34", + "django_id": Decimal("78984"), + "multivariate_feature_state_values": [], + }, + { + "feature": { + "id": Decimal("15062"), + "name": "mv_feature", + "type": "MULTIVARIATE", + }, + "enabled": True, + "feature_state_value": "foo", + "django_id": Decimal("78986"), + "multivariate_feature_state_values": [ + { + "id": Decimal("3404"), + "percentage_allocation": Decimal("30.0"), + "multivariate_feature_option": {"value": "baz"}, + }, + { + "id": Decimal("3402"), + "percentage_allocation": Decimal("30.0"), + "multivariate_feature_option": {"value": "bar"}, + }, + { + "id": Decimal("3405"), + "percentage_allocation": Decimal("0.0"), + "multivariate_feature_option": { + "value": Decimal("1") + }, + }, + { + "id": Decimal("3406"), + "percentage_allocation": Decimal("0.0"), + "multivariate_feature_option": {"value": True}, + }, + ], + }, + ], + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + mtime=0, + ), + }, + id="flagsmith_environments_compressed", + ), pytest.param( EnvironmentAPIKey, "flagsmith_environment_api_key.json", @@ -478,6 +794,133 @@ }, id="flagsmith_environments_v2:_META", ), + pytest.param( + EnvironmentV2MetaCompressed, + "flagsmith_environments_v2:_META_compressed.json", + { + "compressed": True, + "environment_id": "49268", + "document_key": "_META", + "allow_client_traits": True, + "amplitude_config": None, + "dynatrace_config": None, + "environment_api_key": "AQ9T6LixPqYMJkuqGJy3t2", + "feature_states": gzip.compress( + json.dumps( + [ + { + "django_id": 577621, + "enabled": True, + "feature": { + "id": 100298, + "name": "test_feature", + "type": "MULTIVARIATE", + }, + "featurestate_uuid": "42d7805e-a9ac-401c-a7b7-d6583ac5a365", + "feature_segment": None, + "feature_state_value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "multivariate_feature_state_values": [ + { + "id": 185130, + "multivariate_feature_option": { + "id": 20919, + "value": "second", + }, + "mv_fs_value_uuid": "0b02ce41-9965-4c61-8b96-c8d76e3d4a27", + "percentage_allocation": 10.0, + }, + { + "id": 48717, + "multivariate_feature_option": { + "id": 14004, + "value": True, + }, + "mv_fs_value_uuid": "cb05f49c-de1f-44f1-87eb-c3b55d473063", + "percentage_allocation": Decimal("30.0"), + }, + ], + }, + { + "django_id": 1041292, + "enabled": False, + "feature": { + "id": 172422, + "name": "feature", + "type": "STANDARD", + }, + "featurestate_uuid": "58b7b954-1b75-493a-82df-5be0efeedd2a", + "feature_segment": None, + "feature_state_value": 3, + "multivariate_feature_state_values": [], + }, + ], + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + mtime=0, + ), + "heap_config": None, + "hide_disabled_flags": None, + "hide_sensitive_data": False, + "id": Decimal("49268"), + "mixpanel_config": None, + "name": "Development", + "project": gzip.compress( + json.dumps( + { + "enable_realtime_updates": False, + "hide_disabled_flags": False, + "id": Decimal("19368"), + "name": "Example Project", + "organisation": { + "feature_analytics": False, + "id": Decimal("13"), + "name": "Flagsmith", + "persist_trait_data": True, + "stop_serving_flags": False, + }, + "segments": [ + { + "feature_states": [], + "id": Decimal("44126"), + "name": "test", + "rules": [ + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "operator": "EQUAL", + "property_": "test", + "value": "test", + } + ], + "rules": [], + "type": "ANY", + } + ], + "type": "ALL", + } + ], + } + ], + "server_key_only_feature_ids": [], + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + mtime=0, + ), + "rudderstack_config": None, + "segment_config": None, + "updated_at": "2025-11-16T13:28:31.244331+00:00", + "use_identity_composite_key_for_hashing": True, + "use_identity_overrides_in_local_eval": False, + "webhook_config": None, + }, + id="flagsmith_environments_v2:_META_compressed", + ), pytest.param( EnvironmentV2IdentityOverride, "flagsmith_environments_v2:identity_override.json", diff --git a/tests/integration/flagsmith_schemas/test_types.py b/tests/integration/flagsmith_schemas/test_types.py index 9aa4fb2..29e8bd2 100644 --- a/tests/integration/flagsmith_schemas/test_types.py +++ b/tests/integration/flagsmith_schemas/test_types.py @@ -1,7 +1,9 @@ +import gzip + import pytest from pydantic import TypeAdapter, ValidationError -from flagsmith_schemas.types import DynamoFeatureValue +from flagsmith_schemas.types import DynamoFeatureValue, JsonGzipped def test_dynamo_feature_value__not_int__coerces_to_str() -> None: @@ -32,3 +34,18 @@ def test_dynamo_feature_value__long_string__raises_expected() -> None: "msg": "Value error, Dynamo feature state value string length cannot exceed 20000 characters (got 20001 characters).", }.items() ) + + +def test_json_gzipped__valid_json_bytes__accepts_expected() -> None: + # Given + type_adapter: TypeAdapter[JsonGzipped[dict[str, int]]] = TypeAdapter( + JsonGzipped[dict[str, int]] + ) + input_data: dict[str, int] = {"key": 123} + json_bytes = b'{"key":123}' + + # When + result = type_adapter.validate_python(input_data) + + # Then + assert gzip.decompress(bytes(result)) == json_bytes diff --git a/uv.lock b/uv.lock index 2f5c0a6..7892ab8 100644 --- a/uv.lock +++ b/uv.lock @@ -416,6 +416,7 @@ common-core = [ ] flagsmith-schemas = [ { name = "flagsmith-flag-engine" }, + { name = "simplejson" }, { name = "typing-extensions" }, ] task-processor = [ @@ -462,7 +463,7 @@ requires-dist = [ { name = "drf-spectacular", marker = "extra == 'common-core'", specifier = ">=0.28.0,<1" }, { name = "drf-writable-nested", marker = "extra == 'common-core'" }, { name = "environs", marker = "extra == 'common-core'", specifier = "<15" }, - { name = "flagsmith-flag-engine", marker = "extra == 'flagsmith-schemas'", specifier = ">10" }, + { name = "flagsmith-flag-engine", marker = "extra == 'flagsmith-schemas'", specifier = ">6" }, { name = "gunicorn", marker = "extra == 'common-core'", specifier = ">=19.1" }, { name = "prometheus-client", marker = "extra == 'common-core'", specifier = ">=0.0.16" }, { name = "prometheus-client", marker = "extra == 'task-processor'", specifier = ">=0.0.16" }, @@ -471,6 +472,7 @@ requires-dist = [ { name = "pytest-django", marker = "extra == 'test-tools'", specifier = ">=4,<5" }, { name = "requests", marker = "extra == 'common-core'" }, { name = "simplejson", marker = "extra == 'common-core'", specifier = ">=3,<4" }, + { name = "simplejson", marker = "extra == 'flagsmith-schemas'" }, { name = "typing-extensions", marker = "extra == 'flagsmith-schemas'" }, ] provides-extras = ["test-tools", "common-core", "task-processor", "flagsmith-schemas"] @@ -570,33 +572,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "jsonschema" -version = "4.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - [[package]] name = "iregexp-check" version = "0.1.4" @@ -632,6 +607,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0f/6ac19c91217cbe4f41a8ed7c00a13c4efa19831aa0b85f9dfb71aefa096c/jsonpath_rfc9535-0.2.0-py3-none-any.whl", hash = "sha256:76488ac205e13af28dc1f8fccdd4df641a950605faad6c5b6b2451483a5b4624", size = 36492, upload-time = "2025-11-30T09:03:16.849Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "librt" version = "0.7.3"