From 41cea09256632ebed44a69b2ea0b373bef6817d8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 14 Jan 2026 18:52:09 +0000 Subject: [PATCH 01/11] feat(schemas): Support partially gzip-compressed environment documents --- pyproject.toml | 1 + src/flagsmith_schemas/dynamodb.py | 98 +++- src/flagsmith_schemas/types.py | 43 +- src/flagsmith_schemas/validators.py | 21 +- .../data/flagsmith_environments.json | 8 +- .../flagsmith_environments_compressed.json | 295 ++++++++++++ .../data/flagsmith_environments_v2:_META.json | 4 +- ...mith_environments_v2:_META_compressed.json | 109 +++++ .../flagsmith_schemas/test_dynamodb.py | 443 ++++++++++++++++++ .../flagsmith_schemas/test_types.py | 19 +- uv.lock | 56 +-- 11 files changed, 1040 insertions(+), 57 deletions(-) create mode 100644 tests/integration/flagsmith_schemas/data/flagsmith_environments_compressed.json create mode 100644 tests/integration/flagsmith_schemas/data/flagsmith_environments_v2:_META_compressed.json diff --git a/pyproject.toml b/pyproject.toml index 13b854f..4293cf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ optional-dependencies = { test-tools = [ "django-health-check", "prometheus-client (>=0.0.16)", ], flagsmith-schemas = [ + "simplejson", "typing_extensions", "flagsmith-flag-engine>10", ] } diff --git a/src/flagsmith_schemas/dynamodb.py b/src/flagsmith_schemas/dynamodb.py index 7d69594..957c6a1 100644 --- a/src/flagsmith_schemas/dynamodb.py +++ b/src/flagsmith_schemas/dynamodb.py @@ -20,6 +20,7 @@ DynamoFloat, DynamoInt, FeatureType, + JsonGzipped, UUIDStr, ) @@ -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 _UncompressedEnvironmentFields(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 _CompressedEnvironmentFields(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( + _UncompressedEnvironmentFields, + _EnvironmentV1Fields, + _EnvironmentFields, +): """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( + _CompressedEnvironmentFields, + _EnvironmentV1Fields, + _EnvironmentFields, +): + """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( + _UncompressedEnvironmentFields, + _EnvironmentV2MetaFields, + _EnvironmentFields, +): """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( + _CompressedEnvironmentFields, + _EnvironmentV2MetaFields, + _EnvironmentFields, +): + """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..a847859 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -1,10 +1,24 @@ from decimal import Decimal -from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + 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 +27,7 @@ ValidateStrAsISODateTime, ValidateStrAsUUID, ) + from flagsmith_schemas.validators import validate_json_gzipped 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 +41,30 @@ def WithJsonSchema(_: object) -> object: ValidateStrAsISODateTime = ... ValidateStrAsUUID = ... +T = TypeVar("T") + + +class JsonGzipped(Generic[T], bytes): + """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 validate_json_gzipped(_adapter.validate_python(data)) + + 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/validators.py b/src/flagsmith_schemas/validators.py index a43b519..ddfc55d 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -1,11 +1,16 @@ +import gzip import typing from decimal import Decimal +import simplejson as json + from flagsmith_schemas.constants import MAX_STRING_FEATURE_STATE_VALUE_LENGTH if typing.TYPE_CHECKING: from flagsmith_schemas.dynamodb import FeatureState, MultivariateFeatureStateValue - from flagsmith_schemas.types import DynamoFeatureValue + from flagsmith_schemas.types import DynamoFeatureValue, JsonGzipped + +T = typing.TypeVar("T") def validate_dynamo_feature_state_value( @@ -53,3 +58,17 @@ def validate_identity_feature_states( seen.add(feature_id) return values + + +def validate_json_gzipped(value: T) -> "JsonGzipped[T]": + return typing.cast( + "JsonGzipped[T]", + 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..6733d9d 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(result) == json_bytes diff --git a/uv.lock b/uv.lock index 2f5c0a6..1866231 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 = [ @@ -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" From b49b5f2dbacfecddd116d3cf28361ef89c4ce379 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 15 Jan 2026 09:52:08 +0000 Subject: [PATCH 02/11] relax engine dependency --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4293cf7..44e1fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ optional-dependencies = { test-tools = [ ], flagsmith-schemas = [ "simplejson", "typing_extensions", - "flagsmith-flag-engine>10", + "flagsmith-flag-engine>6", ] } authors = [ { name = "Matthew Elwell" }, diff --git a/uv.lock b/uv.lock index 1866231..7892ab8 100644 --- a/uv.lock +++ b/uv.lock @@ -463,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" }, From f4f30d307a0ced1752edbb941f0d6a98e5e60eff Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 10:58:25 +0000 Subject: [PATCH 03/11] clarify utility --- src/flagsmith_schemas/types.py | 4 ++-- src/flagsmith_schemas/utils.py | 23 +++++++++++++++++++++++ src/flagsmith_schemas/validators.py | 21 +-------------------- 3 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 src/flagsmith_schemas/utils.py diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index a847859..33a6611 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -27,7 +27,7 @@ ValidateStrAsISODateTime, ValidateStrAsUUID, ) - from flagsmith_schemas.validators import validate_json_gzipped + 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. @@ -58,7 +58,7 @@ def __get_pydantic_core_schema__( _adapter: TypeAdapter[T] = TypeAdapter(get_args(source_type)[0]) def _validate_json_gzipped(data: Any) -> bytes: - return validate_json_gzipped(_adapter.validate_python(data)) + return json_gzip(_adapter.validate_python(data)) return core_schema.no_info_before_validator_function( _validate_json_gzipped, diff --git a/src/flagsmith_schemas/utils.py b/src/flagsmith_schemas/utils.py new file mode 100644 index 0000000..0d732a3 --- /dev/null +++ b/src/flagsmith_schemas/utils.py @@ -0,0 +1,23 @@ +import gzip +import typing + +import simplejson as json + +if typing.TYPE_CHECKING: + from flagsmith_schemas.types import JsonGzipped + +T = typing.TypeVar("T") + + +def json_gzip(value: T) -> "JsonGzipped[T]": + return typing.cast( + "JsonGzipped[T]", + gzip.compress( + json.dumps( + value, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + mtime=0, + ), + ) diff --git a/src/flagsmith_schemas/validators.py b/src/flagsmith_schemas/validators.py index ddfc55d..a43b519 100644 --- a/src/flagsmith_schemas/validators.py +++ b/src/flagsmith_schemas/validators.py @@ -1,16 +1,11 @@ -import gzip import typing from decimal import Decimal -import simplejson as json - from flagsmith_schemas.constants import MAX_STRING_FEATURE_STATE_VALUE_LENGTH if typing.TYPE_CHECKING: from flagsmith_schemas.dynamodb import FeatureState, MultivariateFeatureStateValue - from flagsmith_schemas.types import DynamoFeatureValue, JsonGzipped - -T = typing.TypeVar("T") + from flagsmith_schemas.types import DynamoFeatureValue def validate_dynamo_feature_state_value( @@ -58,17 +53,3 @@ def validate_identity_feature_states( seen.add(feature_id) return values - - -def validate_json_gzipped(value: T) -> "JsonGzipped[T]": - return typing.cast( - "JsonGzipped[T]", - gzip.compress( - json.dumps( - value, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8"), - mtime=0, - ), - ) From 63c54ebe7e9da1e7b2d1a29fb50013f114512b02 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 12:11:44 +0000 Subject: [PATCH 04/11] improve JsonGzipped type --- src/flagsmith_schemas/types.py | 9 +++++++- src/flagsmith_schemas/utils.py | 22 +++++++------------ .../flagsmith_schemas/test_types.py | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index 33a6611..9ebe888 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -5,6 +5,7 @@ Any, Generic, Literal, + SupportsBytes, TypeAlias, TypeVar, get_args, @@ -44,7 +45,13 @@ def WithJsonSchema(_: object) -> object: T = TypeVar("T") -class JsonGzipped(Generic[T], bytes): +class DynamoBinary(SupportsBytes): + """boto3's wrapper type for bytes stored in DynamoDB.""" + + value: bytes | bytearray + + +class JsonGzipped(Generic[T], DynamoBinary): """A gzipped JSON blob representing a value of type `T`.""" if PYDANTIC_INSTALLED: diff --git a/src/flagsmith_schemas/utils.py b/src/flagsmith_schemas/utils.py index 0d732a3..6dec91a 100644 --- a/src/flagsmith_schemas/utils.py +++ b/src/flagsmith_schemas/utils.py @@ -3,21 +3,15 @@ import simplejson as json -if typing.TYPE_CHECKING: - from flagsmith_schemas.types import JsonGzipped - T = typing.TypeVar("T") -def json_gzip(value: T) -> "JsonGzipped[T]": - return typing.cast( - "JsonGzipped[T]", - gzip.compress( - json.dumps( - value, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8"), - mtime=0, - ), +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/test_types.py b/tests/integration/flagsmith_schemas/test_types.py index 6733d9d..29e8bd2 100644 --- a/tests/integration/flagsmith_schemas/test_types.py +++ b/tests/integration/flagsmith_schemas/test_types.py @@ -48,4 +48,4 @@ def test_json_gzipped__valid_json_bytes__accepts_expected() -> None: result = type_adapter.validate_python(input_data) # Then - assert gzip.decompress(result) == json_bytes + assert gzip.decompress(bytes(result)) == json_bytes From 7e22054b251bb7be22576d75cdf5b26ac352749e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 12:18:05 +0000 Subject: [PATCH 05/11] fix MRO --- src/flagsmith_schemas/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index 9ebe888..f983cb1 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -51,7 +51,7 @@ class DynamoBinary(SupportsBytes): value: bytes | bytearray -class JsonGzipped(Generic[T], DynamoBinary): +class JsonGzipped(DynamoBinary, Generic[T]): """A gzipped JSON blob representing a value of type `T`.""" if PYDANTIC_INSTALLED: From e5b557d2ecd27a8879cf743817406062f7a9ccb1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 12:21:54 +0000 Subject: [PATCH 06/11] document --- src/flagsmith_schemas/types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/flagsmith_schemas/types.py b/src/flagsmith_schemas/types.py index f983cb1..87cc7db 100644 --- a/src/flagsmith_schemas/types.py +++ b/src/flagsmith_schemas/types.py @@ -67,6 +67,9 @@ def __get_pydantic_core_schema__( 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), From 07c5f8e04fbcf34d40ab73df7295c5577e962947 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 15:10:26 +0000 Subject: [PATCH 07/11] Apply suggestions from code review Co-authored-by: Evandro Myller <22429+emyller@users.noreply.github.com> --- src/flagsmith_schemas/dynamodb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flagsmith_schemas/dynamodb.py b/src/flagsmith_schemas/dynamodb.py index 957c6a1..98b9106 100644 --- a/src/flagsmith_schemas/dynamodb.py +++ b/src/flagsmith_schemas/dynamodb.py @@ -259,7 +259,7 @@ class _EnvironmentV2MetaFields(TypedDict): """Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema.""" -class _UncompressedEnvironmentFields(TypedDict): +class _EnvironmentFieldsUncompressed(TypedDict): """Common fields for uncompressed environment documents.""" project: Project @@ -270,7 +270,7 @@ class _UncompressedEnvironmentFields(TypedDict): """Either `False` or absent to indicate the data is uncompressed.""" -class _CompressedEnvironmentFields(TypedDict): +class _EnvironmentFieldsCompressed(TypedDict): """Common fields for compressed environment documents.""" project: JsonGzipped[Project] From d8e7e0ce5a0ff7c198d46640a7676d9edb29b303 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 15:08:45 +0000 Subject: [PATCH 08/11] cleanup --- src/flagsmith_schemas/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/flagsmith_schemas/utils.py b/src/flagsmith_schemas/utils.py index 6dec91a..d237e71 100644 --- a/src/flagsmith_schemas/utils.py +++ b/src/flagsmith_schemas/utils.py @@ -3,8 +3,6 @@ import simplejson as json -T = typing.TypeVar("T") - def json_gzip(value: typing.Any) -> bytes: return gzip.compress( From 212abb26c6707d79e13d3209d970853dffa27d33 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 15:12:08 +0000 Subject: [PATCH 09/11] Update src/flagsmith_schemas/dynamodb.py Co-authored-by: Evandro Myller <22429+emyller@users.noreply.github.com> --- src/flagsmith_schemas/dynamodb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flagsmith_schemas/dynamodb.py b/src/flagsmith_schemas/dynamodb.py index 98b9106..bb6d9d4 100644 --- a/src/flagsmith_schemas/dynamodb.py +++ b/src/flagsmith_schemas/dynamodb.py @@ -201,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] From 04882f0f8ffa13882d445133084104f654ff960c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 15:14:00 +0000 Subject: [PATCH 10/11] naming fix --- src/flagsmith_schemas/dynamodb.py | 20 +- src/flagsmith_schemas/dynamodb/types.py | 480 ++++++++++++++++++++++++ 2 files changed, 490 insertions(+), 10 deletions(-) create mode 100644 src/flagsmith_schemas/dynamodb/types.py diff --git a/src/flagsmith_schemas/dynamodb.py b/src/flagsmith_schemas/dynamodb.py index bb6d9d4..ed9c3ae 100644 --- a/src/flagsmith_schemas/dynamodb.py +++ b/src/flagsmith_schemas/dynamodb.py @@ -259,7 +259,7 @@ class _EnvironmentV2MetaFields(TypedDict): """Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema.""" -class _EnvironmentFieldsUncompressed(TypedDict): +class _EnvironmentBaseFieldsUncompressed(TypedDict): """Common fields for uncompressed environment documents.""" project: Project @@ -270,7 +270,7 @@ class _EnvironmentFieldsUncompressed(TypedDict): """Either `False` or absent to indicate the data is uncompressed.""" -class _EnvironmentFieldsCompressed(TypedDict): +class _EnvironmentBaseFieldsCompressed(TypedDict): """Common fields for compressed environment documents.""" project: JsonGzipped[Project] @@ -337,9 +337,9 @@ class Identity(TypedDict): class Environment( - _UncompressedEnvironmentFields, + _EnvironmentBaseFieldsUncompressed, _EnvironmentV1Fields, - _EnvironmentFields, + _EnvironmentBaseFields, ): """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. @@ -348,9 +348,9 @@ class Environment( class EnvironmentCompressed( - _CompressedEnvironmentFields, + _EnvironmentBaseFieldsCompressed, _EnvironmentV1Fields, - _EnvironmentFields, + _EnvironmentBaseFields, ): """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. Has compressed fields. @@ -360,9 +360,9 @@ class EnvironmentCompressed( class EnvironmentV2Meta( - _UncompressedEnvironmentFields, + _EnvironmentBaseFieldsUncompressed, _EnvironmentV2MetaFields, - _EnvironmentFields, + _EnvironmentBaseFields, ): """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. @@ -371,9 +371,9 @@ class EnvironmentV2Meta( class EnvironmentV2MetaCompressed( - _CompressedEnvironmentFields, + _EnvironmentBaseFieldsCompressed, _EnvironmentV2MetaFields, - _EnvironmentFields, + _EnvironmentBaseFields, ): """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. Has compressed fields. diff --git a/src/flagsmith_schemas/dynamodb/types.py b/src/flagsmith_schemas/dynamodb/types.py new file mode 100644 index 0000000..a2e2f84 --- /dev/null +++ b/src/flagsmith_schemas/dynamodb/types.py @@ -0,0 +1,480 @@ +""" +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 decimal import Decimal +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + TypeAlias, + TypeVar, + get_args, +) + +from boto3.dynamodb.types import Binary # type: ignore[import-untyped] +from flag_engine.segments.types import ConditionOperator, RuleType +from typing_extensions import NotRequired, TypedDict + +from flagsmith_schemas.constants import PYDANTIC_INSTALLED +from flagsmith_schemas.types import FeatureType, UUIDStr + +if PYDANTIC_INSTALLED: + from pydantic import GetCoreSchemaHandler, TypeAdapter + from pydantic_core import core_schema + + from flagsmith_schemas.pydantic_types import ( + ValidateDecimalAsFloat, + ValidateDecimalAsInt, + ValidateDynamoFeatureStateValue, + ValidateIdentityFeatureStatesList, + ValidateMultivariateFeatureValuesList, + ValidateStrAsISODateTime, + ) + from flagsmith_schemas.utils import json_gzip +elif not TYPE_CHECKING: + ValidateDecimalAsFloat = ... + ValidateDecimalAsInt = ... + ValidateDynamoFeatureStateValue = ... + ValidateStrAsISODateTime = ... + +T = TypeVar("T") + + +class JsonGzipped(Generic[T], Binary): # type: ignore[misc] + """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)) + + 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. + +DynamoDB represents all numbers as `Decimal`. +`DynamoInt` indicates that the value should be treated as an integer. +""" + +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. +""" + +DateTimeStr: TypeAlias = Annotated[str, ValidateStrAsISODateTime] +"""A string representing a date and time in ISO 8601 format.""" + +DynamoFeatureValue: TypeAlias = Annotated[ + DynamoInt | bool | str | None, + ValidateDynamoFeatureStateValue, +] +"""Represents the value of a Flagsmith feature stored in DynamoDB. Can be stored a boolean, an integer, or a string. + +The default (SaaS) maximum length for strings is 20000 characters. +""" + +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. +- Boolean values are stored as booleans. +- All other values are stored as strings. +- Maximum length for strings is 2000 characters. + +This type does not include complex structures like lists or dictionaries. +""" + + +class Feature(TypedDict): + """Represents a Flagsmith feature, defined at project level.""" + + id: DynamoInt + """Unique identifier for the feature in Core.""" + name: str + """Name of the feature. Must be unique within a project.""" + type: FeatureType + + +class MultivariateFeatureOption(TypedDict): + """Represents a single multivariate feature option in the Flagsmith UI.""" + + 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: DynamoFeatureValue + """The feature state value that should be served when this option's parent multivariate feature state is selected by the engine.""" + + +class MultivariateFeatureStateValue(TypedDict): + """Represents a multivariate feature state value. + + Identity overrides are meant to hold only one of these, solely to inform the UI which option is selected for the given identity. + """ + + id: NotRequired[DynamoInt | None] + """Unique identifier for the multivariate feature state value in Core. Used for multivariate bucketing. If feature state created via `edge-identities` APIs in Core, this can be missing or `None`.""" + mv_fs_value_uuid: NotRequired[UUIDStr] + """The UUID for this multivariate feature state value. Should be used for multivariate bucketing if `id` is `None`.""" + percentage_allocation: DynamoFloat + """The percentage allocation for this multivariate feature state value. Should be between or equal to 0 and 100.""" + multivariate_feature_option: MultivariateFeatureOption + """The multivariate feature option that this value corresponds to.""" + + +class FeatureSegment(TypedDict): + """Represents data specific to a segment feature override.""" + + priority: NotRequired[DynamoInt | None] + """The priority of this segment feature override. Lower numbers indicate stronger priority. If `None` or not set, the weakest priority is assumed.""" + + +class FeatureState(TypedDict): + """Used to define the state of a feature for an environment, segment overrides, and identity overrides.""" + + feature: Feature + """The feature that this feature state is for.""" + enabled: bool + """Whether the feature is enabled or disabled.""" + 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`.""" + featurestate_uuid: NotRequired[UUIDStr] + """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[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. + """ + + +class Trait(TypedDict): + """Represents a key-value pair associated with an identity.""" + + trait_key: str + """Trait key.""" + trait_value: DynamoContextValue + """Trait value.""" + + +class SegmentCondition(TypedDict): + """Represents a condition within a segment rule used by Flagsmith engine.""" + + operator: ConditionOperator + """Operator to be applied for this condition.""" + value: NotRequired[str | None] + """Value to be compared against in this condition. May be `None` for `IS_SET` and `IS_NOT_SET` operators.""" + property_: NotRequired[str | None] + """The property (context key) this condition applies to. May be `None` for the `PERCENTAGE_SPLIT` operator. + + Named `property_` to avoid conflict with Python's `property` built-in. + """ + + +class SegmentRule(TypedDict): + """Represents a rule within a segment used by Flagsmith engine.""" + + type: RuleType + """Type of the rule, defining how conditions are evaluated.""" + rules: "list[SegmentRule]" + """Nested rules within this rule.""" + conditions: list[SegmentCondition] + """Conditions that must be met for this rule, evaluated based on the rule type.""" + + +class Segment(TypedDict): + """Represents a Flagsmith segment. Carries rules, feature overrides, and segment rules.""" + + id: DynamoInt + """Unique identifier for the segment in Core.""" + name: str + """Name of the segment.""" + rules: list[SegmentRule] + """List of rules within the segment.""" + feature_states: NotRequired[list[FeatureState]] + """List of segment overrides.""" + + +class Organisation(TypedDict): + """Represents data about a Flagsmith organisation. Carries settings necessary for an SDK API operation.""" + + id: DynamoInt + """Unique identifier for the organisation in Core.""" + name: str + """Organisation name.""" + feature_analytics: NotRequired[bool] + """Whether the SDK API should log feature analytics events for this organisation. Defaults to `False`.""" + stop_serving_flags: NotRequired[bool] + """Whether flag serving is disabled for this organisation. Defaults to `False`.""" + persist_trait_data: NotRequired[bool] + """If set to `False`, trait data will never be persisted for this organisation. Defaults to `True`.""" + + +class Project(TypedDict): + """Represents data about a Flagsmith project. Carries settings necessary for an SDK API operation.""" + + id: DynamoInt + """Unique identifier for the project in Core.""" + name: str + """Project name.""" + organisation: Organisation + """The organisation that this project belongs to.""" + segments: list[Segment] + """List of segments.""" + server_key_only_feature_ids: NotRequired[list[DynamoInt]] + """List of feature IDs that are skipped when the SDK API serves flags for a public client-side key.""" + enable_realtime_updates: NotRequired[bool] + """Whether the SDK API should use real-time updates. Defaults to `False`. Not currently used neither by SDK APIs nor by SDKs themselves.""" + hide_disabled_flags: NotRequired[bool | None] + """Whether the SDK API should hide disabled flags for this project. Defaults to `False`.""" + + +class Integration(TypedDict): + """Represents evaluation integration data.""" + + api_key: NotRequired[str | None] + """API key for the integration.""" + base_url: NotRequired[str | None] + """Base URL for the integration.""" + + +class DynatraceIntegration(Integration): + """Represents Dynatrace evaluation integration data.""" + + entity_selector: str + """A Dynatrace entity selector string.""" + + +class Webhook(TypedDict): + """Represents a webhook configuration.""" + + url: str + """Webhook target URL.""" + secret: str + """Secret used to sign webhook payloads.""" + + +class _EnvironmentBaseFields(TypedDict): + """Common fields for Environment documents.""" + + name: NotRequired[str] + """Environment name. Defaults to an empty string if not set.""" + updated_at: NotRequired[DateTimeStr | None] + """Last updated timestamp. If not set, current timestamp should be assumed.""" + + 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] + """Whether the SDK API should hide sensitive data for this environment. Defaults to `False`.""" + hide_disabled_flags: NotRequired[bool | None] + """Whether the SDK API should hide disabled flags for this environment. If `None`, the SDK API should fall back to project-level setting.""" + use_identity_composite_key_for_hashing: NotRequired[bool] + """Whether the SDK API should set `$.identity.key` in engine evaluation context to identity's composite key. Defaults to `False`.""" + use_identity_overrides_in_local_eval: NotRequired[bool] + """Whether the SDK API should return identity overrides as part of the environment document. Defaults to `False`.""" + + amplitude_config: NotRequired[Integration | None] + """Amplitude integration configuration.""" + dynatrace_config: NotRequired[DynatraceIntegration | None] + """Dynatrace integration configuration.""" + heap_config: NotRequired[Integration | None] + """Heap integration configuration.""" + mixpanel_config: NotRequired[Integration | None] + """Mixpanel integration configuration.""" + rudderstack_config: NotRequired[Integration | None] + """RudderStack integration configuration.""" + segment_config: NotRequired[Integration | None] + """Segment integration configuration.""" + webhook_config: NotRequired[Webhook | None] + """Webhook configuration.""" + + +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 _UncompressedEnvironmentFields(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 _CompressedEnvironmentFields(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): + """Represents a server-side API key for a Flagsmith environment. + + **DynamoDB table**: `flagsmith_environment_api_key` + """ + + id: DynamoInt + """Unique identifier for the environment API key in Core. **INDEXED**.""" + key: str + """The server-side API key string, e.g. `"ser.xxxxxxxxxxxxx"`. **INDEXED**.""" + created_at: DateTimeStr + """Creation timestamp.""" + name: str + """Name of the API key.""" + client_api_key: str + """The corresponding public client-side API key.""" + expires_at: NotRequired[DateTimeStr | None] + """Expiration timestamp. If `None`, the key does not expire.""" + active: bool + """Whether the key is active. Defaults to `True`.""" + + +class Identity(TypedDict): + """Represents a Flagsmith identity within an environment. Carries traits and feature overrides. + Used for per-identity flag evaluations in remote evaluation mode. + + **DynamoDB table**: `flagsmith_identities` + """ + + identifier: str + """Unique identifier for the identity. **INDEXED**.""" + environment_api_key: str + """API key of the environment this identity belongs to. Used to scope the identity within a specific environment. **INDEXED**.""" + identity_uuid: UUIDStr + """The UUID for this identity. **INDEXED**.""" + composite_key: str + """A composite key combining the environment and identifier. **INDEXED**. + + Generated as: `{environment_api_key}_{identifier}`. + """ + created_date: DateTimeStr + """Creation timestamp.""" + 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.""" + django_id: NotRequired[DynamoInt | None] + """Unique identifier for the identity in Core. If identity created via Core's `edge-identities` API, this can be missing or `None`.""" + + +class Environment( + _UncompressedEnvironmentFields, + _EnvironmentV1Fields, + _EnvironmentBaseFields, +): + """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. + + **DynamoDB table**: `flagsmith_environments` + """ + + +class EnvironmentCompressed( + _CompressedEnvironmentFields, + _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( + _UncompressedEnvironmentFields, + _EnvironmentV2MetaFields, + _EnvironmentBaseFields, +): + """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. + + **DynamoDB table**: `flagsmith_environments_v2` + """ + + +class EnvironmentV2MetaCompressed( + _CompressedEnvironmentFields, + _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): + """Represents an identity override. + Used for per-identity flag evaluations in local evaluation mode. Presented as part of the API environment document. + + **DynamoDB table**: `flagsmith_environments_v2` + """ + + environment_id: str + """Unique identifier for the environment in Core. **INDEXED**.""" + document_key: str + """The document key for this identity override, formatted as `identity_override:{feature Core ID}:{identity UUID}`. **INDEXED**.""" + environment_api_key: str + """Public client-side API key for the environment. **INDEXED**.""" + identifier: str + """Unique identifier for the identity. **INDEXED**.""" + identity_uuid: str + """The UUID for this identity, used by `edge-identities` APIs in Core. **INDEXED**.""" + feature_state: FeatureState + """The feature state override for this identity.""" From 47fe4d1da492ab97dd7e07c9182e5abf8e50e8eb Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Jan 2026 15:15:57 +0000 Subject: [PATCH 11/11] cleanup --- src/flagsmith_schemas/dynamodb/types.py | 480 ------------------------ 1 file changed, 480 deletions(-) delete mode 100644 src/flagsmith_schemas/dynamodb/types.py diff --git a/src/flagsmith_schemas/dynamodb/types.py b/src/flagsmith_schemas/dynamodb/types.py deleted file mode 100644 index a2e2f84..0000000 --- a/src/flagsmith_schemas/dynamodb/types.py +++ /dev/null @@ -1,480 +0,0 @@ -""" -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 decimal import Decimal -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - Generic, - Literal, - TypeAlias, - TypeVar, - get_args, -) - -from boto3.dynamodb.types import Binary # type: ignore[import-untyped] -from flag_engine.segments.types import ConditionOperator, RuleType -from typing_extensions import NotRequired, TypedDict - -from flagsmith_schemas.constants import PYDANTIC_INSTALLED -from flagsmith_schemas.types import FeatureType, UUIDStr - -if PYDANTIC_INSTALLED: - from pydantic import GetCoreSchemaHandler, TypeAdapter - from pydantic_core import core_schema - - from flagsmith_schemas.pydantic_types import ( - ValidateDecimalAsFloat, - ValidateDecimalAsInt, - ValidateDynamoFeatureStateValue, - ValidateIdentityFeatureStatesList, - ValidateMultivariateFeatureValuesList, - ValidateStrAsISODateTime, - ) - from flagsmith_schemas.utils import json_gzip -elif not TYPE_CHECKING: - ValidateDecimalAsFloat = ... - ValidateDecimalAsInt = ... - ValidateDynamoFeatureStateValue = ... - ValidateStrAsISODateTime = ... - -T = TypeVar("T") - - -class JsonGzipped(Generic[T], Binary): # type: ignore[misc] - """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)) - - 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. - -DynamoDB represents all numbers as `Decimal`. -`DynamoInt` indicates that the value should be treated as an integer. -""" - -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. -""" - -DateTimeStr: TypeAlias = Annotated[str, ValidateStrAsISODateTime] -"""A string representing a date and time in ISO 8601 format.""" - -DynamoFeatureValue: TypeAlias = Annotated[ - DynamoInt | bool | str | None, - ValidateDynamoFeatureStateValue, -] -"""Represents the value of a Flagsmith feature stored in DynamoDB. Can be stored a boolean, an integer, or a string. - -The default (SaaS) maximum length for strings is 20000 characters. -""" - -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. -- Boolean values are stored as booleans. -- All other values are stored as strings. -- Maximum length for strings is 2000 characters. - -This type does not include complex structures like lists or dictionaries. -""" - - -class Feature(TypedDict): - """Represents a Flagsmith feature, defined at project level.""" - - id: DynamoInt - """Unique identifier for the feature in Core.""" - name: str - """Name of the feature. Must be unique within a project.""" - type: FeatureType - - -class MultivariateFeatureOption(TypedDict): - """Represents a single multivariate feature option in the Flagsmith UI.""" - - 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: DynamoFeatureValue - """The feature state value that should be served when this option's parent multivariate feature state is selected by the engine.""" - - -class MultivariateFeatureStateValue(TypedDict): - """Represents a multivariate feature state value. - - Identity overrides are meant to hold only one of these, solely to inform the UI which option is selected for the given identity. - """ - - id: NotRequired[DynamoInt | None] - """Unique identifier for the multivariate feature state value in Core. Used for multivariate bucketing. If feature state created via `edge-identities` APIs in Core, this can be missing or `None`.""" - mv_fs_value_uuid: NotRequired[UUIDStr] - """The UUID for this multivariate feature state value. Should be used for multivariate bucketing if `id` is `None`.""" - percentage_allocation: DynamoFloat - """The percentage allocation for this multivariate feature state value. Should be between or equal to 0 and 100.""" - multivariate_feature_option: MultivariateFeatureOption - """The multivariate feature option that this value corresponds to.""" - - -class FeatureSegment(TypedDict): - """Represents data specific to a segment feature override.""" - - priority: NotRequired[DynamoInt | None] - """The priority of this segment feature override. Lower numbers indicate stronger priority. If `None` or not set, the weakest priority is assumed.""" - - -class FeatureState(TypedDict): - """Used to define the state of a feature for an environment, segment overrides, and identity overrides.""" - - feature: Feature - """The feature that this feature state is for.""" - enabled: bool - """Whether the feature is enabled or disabled.""" - 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`.""" - featurestate_uuid: NotRequired[UUIDStr] - """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[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. - """ - - -class Trait(TypedDict): - """Represents a key-value pair associated with an identity.""" - - trait_key: str - """Trait key.""" - trait_value: DynamoContextValue - """Trait value.""" - - -class SegmentCondition(TypedDict): - """Represents a condition within a segment rule used by Flagsmith engine.""" - - operator: ConditionOperator - """Operator to be applied for this condition.""" - value: NotRequired[str | None] - """Value to be compared against in this condition. May be `None` for `IS_SET` and `IS_NOT_SET` operators.""" - property_: NotRequired[str | None] - """The property (context key) this condition applies to. May be `None` for the `PERCENTAGE_SPLIT` operator. - - Named `property_` to avoid conflict with Python's `property` built-in. - """ - - -class SegmentRule(TypedDict): - """Represents a rule within a segment used by Flagsmith engine.""" - - type: RuleType - """Type of the rule, defining how conditions are evaluated.""" - rules: "list[SegmentRule]" - """Nested rules within this rule.""" - conditions: list[SegmentCondition] - """Conditions that must be met for this rule, evaluated based on the rule type.""" - - -class Segment(TypedDict): - """Represents a Flagsmith segment. Carries rules, feature overrides, and segment rules.""" - - id: DynamoInt - """Unique identifier for the segment in Core.""" - name: str - """Name of the segment.""" - rules: list[SegmentRule] - """List of rules within the segment.""" - feature_states: NotRequired[list[FeatureState]] - """List of segment overrides.""" - - -class Organisation(TypedDict): - """Represents data about a Flagsmith organisation. Carries settings necessary for an SDK API operation.""" - - id: DynamoInt - """Unique identifier for the organisation in Core.""" - name: str - """Organisation name.""" - feature_analytics: NotRequired[bool] - """Whether the SDK API should log feature analytics events for this organisation. Defaults to `False`.""" - stop_serving_flags: NotRequired[bool] - """Whether flag serving is disabled for this organisation. Defaults to `False`.""" - persist_trait_data: NotRequired[bool] - """If set to `False`, trait data will never be persisted for this organisation. Defaults to `True`.""" - - -class Project(TypedDict): - """Represents data about a Flagsmith project. Carries settings necessary for an SDK API operation.""" - - id: DynamoInt - """Unique identifier for the project in Core.""" - name: str - """Project name.""" - organisation: Organisation - """The organisation that this project belongs to.""" - segments: list[Segment] - """List of segments.""" - server_key_only_feature_ids: NotRequired[list[DynamoInt]] - """List of feature IDs that are skipped when the SDK API serves flags for a public client-side key.""" - enable_realtime_updates: NotRequired[bool] - """Whether the SDK API should use real-time updates. Defaults to `False`. Not currently used neither by SDK APIs nor by SDKs themselves.""" - hide_disabled_flags: NotRequired[bool | None] - """Whether the SDK API should hide disabled flags for this project. Defaults to `False`.""" - - -class Integration(TypedDict): - """Represents evaluation integration data.""" - - api_key: NotRequired[str | None] - """API key for the integration.""" - base_url: NotRequired[str | None] - """Base URL for the integration.""" - - -class DynatraceIntegration(Integration): - """Represents Dynatrace evaluation integration data.""" - - entity_selector: str - """A Dynatrace entity selector string.""" - - -class Webhook(TypedDict): - """Represents a webhook configuration.""" - - url: str - """Webhook target URL.""" - secret: str - """Secret used to sign webhook payloads.""" - - -class _EnvironmentBaseFields(TypedDict): - """Common fields for Environment documents.""" - - name: NotRequired[str] - """Environment name. Defaults to an empty string if not set.""" - updated_at: NotRequired[DateTimeStr | None] - """Last updated timestamp. If not set, current timestamp should be assumed.""" - - 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] - """Whether the SDK API should hide sensitive data for this environment. Defaults to `False`.""" - hide_disabled_flags: NotRequired[bool | None] - """Whether the SDK API should hide disabled flags for this environment. If `None`, the SDK API should fall back to project-level setting.""" - use_identity_composite_key_for_hashing: NotRequired[bool] - """Whether the SDK API should set `$.identity.key` in engine evaluation context to identity's composite key. Defaults to `False`.""" - use_identity_overrides_in_local_eval: NotRequired[bool] - """Whether the SDK API should return identity overrides as part of the environment document. Defaults to `False`.""" - - amplitude_config: NotRequired[Integration | None] - """Amplitude integration configuration.""" - dynatrace_config: NotRequired[DynatraceIntegration | None] - """Dynatrace integration configuration.""" - heap_config: NotRequired[Integration | None] - """Heap integration configuration.""" - mixpanel_config: NotRequired[Integration | None] - """Mixpanel integration configuration.""" - rudderstack_config: NotRequired[Integration | None] - """RudderStack integration configuration.""" - segment_config: NotRequired[Integration | None] - """Segment integration configuration.""" - webhook_config: NotRequired[Webhook | None] - """Webhook configuration.""" - - -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 _UncompressedEnvironmentFields(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 _CompressedEnvironmentFields(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): - """Represents a server-side API key for a Flagsmith environment. - - **DynamoDB table**: `flagsmith_environment_api_key` - """ - - id: DynamoInt - """Unique identifier for the environment API key in Core. **INDEXED**.""" - key: str - """The server-side API key string, e.g. `"ser.xxxxxxxxxxxxx"`. **INDEXED**.""" - created_at: DateTimeStr - """Creation timestamp.""" - name: str - """Name of the API key.""" - client_api_key: str - """The corresponding public client-side API key.""" - expires_at: NotRequired[DateTimeStr | None] - """Expiration timestamp. If `None`, the key does not expire.""" - active: bool - """Whether the key is active. Defaults to `True`.""" - - -class Identity(TypedDict): - """Represents a Flagsmith identity within an environment. Carries traits and feature overrides. - Used for per-identity flag evaluations in remote evaluation mode. - - **DynamoDB table**: `flagsmith_identities` - """ - - identifier: str - """Unique identifier for the identity. **INDEXED**.""" - environment_api_key: str - """API key of the environment this identity belongs to. Used to scope the identity within a specific environment. **INDEXED**.""" - identity_uuid: UUIDStr - """The UUID for this identity. **INDEXED**.""" - composite_key: str - """A composite key combining the environment and identifier. **INDEXED**. - - Generated as: `{environment_api_key}_{identifier}`. - """ - created_date: DateTimeStr - """Creation timestamp.""" - 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.""" - django_id: NotRequired[DynamoInt | None] - """Unique identifier for the identity in Core. If identity created via Core's `edge-identities` API, this can be missing or `None`.""" - - -class Environment( - _UncompressedEnvironmentFields, - _EnvironmentV1Fields, - _EnvironmentBaseFields, -): - """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. - - **DynamoDB table**: `flagsmith_environments` - """ - - -class EnvironmentCompressed( - _CompressedEnvironmentFields, - _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( - _UncompressedEnvironmentFields, - _EnvironmentV2MetaFields, - _EnvironmentBaseFields, -): - """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment. - - **DynamoDB table**: `flagsmith_environments_v2` - """ - - -class EnvironmentV2MetaCompressed( - _CompressedEnvironmentFields, - _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): - """Represents an identity override. - Used for per-identity flag evaluations in local evaluation mode. Presented as part of the API environment document. - - **DynamoDB table**: `flagsmith_environments_v2` - """ - - environment_id: str - """Unique identifier for the environment in Core. **INDEXED**.""" - document_key: str - """The document key for this identity override, formatted as `identity_override:{feature Core ID}:{identity UUID}`. **INDEXED**.""" - environment_api_key: str - """Public client-side API key for the environment. **INDEXED**.""" - identifier: str - """Unique identifier for the identity. **INDEXED**.""" - identity_uuid: str - """The UUID for this identity, used by `edge-identities` APIs in Core. **INDEXED**.""" - feature_state: FeatureState - """The feature state override for this identity."""