Skip to content

Commit 6cb67b6

Browse files
khvn26emyller
andauthored
feat(schemas): Support partially gzip-compressed environment documents (#157)
Co-authored-by: Evandro Myller <[email protected]>
1 parent e906002 commit 6cb67b6

File tree

11 files changed

+1019
-30
lines changed

11 files changed

+1019
-30
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ optional-dependencies = { test-tools = [
2626
"django-health-check",
2727
"prometheus-client (>=0.0.16)",
2828
], flagsmith-schemas = [
29+
"simplejson",
2930
"typing_extensions",
3031
"flagsmith-flag-engine>6",
3132
] }

src/flagsmith_schemas/dynamodb.py

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
DynamoFloat,
2121
DynamoInt,
2222
FeatureType,
23+
JsonGzipped,
2324
UUIDStr,
2425
)
2526

@@ -200,19 +201,14 @@ class Webhook(TypedDict):
200201
"""Secret used to sign webhook payloads."""
201202

202203

203-
class _EnvironmentFields(TypedDict):
204+
class _EnvironmentBaseFields(TypedDict):
204205
"""Common fields for Environment documents."""
205206

206207
name: NotRequired[str]
207208
"""Environment name. Defaults to an empty string if not set."""
208209
updated_at: NotRequired[DateTimeStr | None]
209210
"""Last updated timestamp. If not set, current timestamp should be assumed."""
210211

211-
project: Project
212-
"""Project-specific data for this environment."""
213-
feature_states: list[FeatureState]
214-
"""List of feature states representing the environment defaults."""
215-
216212
allow_client_traits: NotRequired[bool]
217213
"""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`."""
218214
hide_sensitive_data: NotRequired[bool]
@@ -240,7 +236,52 @@ class _EnvironmentFields(TypedDict):
240236
"""Webhook configuration."""
241237

242238

243-
### Root document schemas below. Indexed fields are marked as **INDEXED** in the docstrings. ###
239+
class _EnvironmentV1Fields(TypedDict):
240+
"""Common fields for environment documents in `flagsmith_environments`."""
241+
242+
api_key: str
243+
"""Public client-side API key for the environment. **INDEXED**."""
244+
id: DynamoInt
245+
"""Unique identifier for the environment in Core."""
246+
247+
248+
class _EnvironmentV2MetaFields(TypedDict):
249+
"""Common fields for environment documents in `flagsmith_environments_v2`."""
250+
251+
environment_id: str
252+
"""Unique identifier for the environment in Core. Same as `Environment.id`, but string-typed to reduce coupling with Core's type definitions **INDEXED**."""
253+
environment_api_key: str
254+
"""Public client-side API key for the environment. **INDEXED**."""
255+
document_key: Literal["_META"]
256+
"""The fixed document key for the environment v2 document. Always `"_META"`. **INDEXED**."""
257+
258+
id: DynamoInt
259+
"""Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema."""
260+
261+
262+
class _EnvironmentBaseFieldsUncompressed(TypedDict):
263+
"""Common fields for uncompressed environment documents."""
264+
265+
project: Project
266+
"""Project-specific data for this environment."""
267+
feature_states: list[FeatureState]
268+
"""List of feature states representing the environment defaults."""
269+
compressed: NotRequired[Literal[False]]
270+
"""Either `False` or absent to indicate the data is uncompressed."""
271+
272+
273+
class _EnvironmentBaseFieldsCompressed(TypedDict):
274+
"""Common fields for compressed environment documents."""
275+
276+
project: JsonGzipped[Project]
277+
"""Project-specific data for this environment. **COMPRESSED**."""
278+
feature_states: JsonGzipped[list[FeatureState]]
279+
"""List of feature states representing the environment defaults. **COMPRESSED**."""
280+
compressed: Literal[True]
281+
"""Always `True` to indicate the data is compressed."""
282+
283+
284+
### Root document schemas below. Indexed fields are marked as **INDEXED** in the docstrings. Compressed fields are marked as **COMPRESSED**. ###
244285

245286

246287
class EnvironmentAPIKey(TypedDict):
@@ -295,33 +336,50 @@ class Identity(TypedDict):
295336
"""Unique identifier for the identity in Core. If identity created via Core's `edge-identities` API, this can be missing or `None`."""
296337

297338

298-
class Environment(_EnvironmentFields):
339+
class Environment(
340+
_EnvironmentBaseFieldsUncompressed,
341+
_EnvironmentV1Fields,
342+
_EnvironmentBaseFields,
343+
):
299344
"""Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
300345
301346
**DynamoDB table**: `flagsmith_environments`
302347
"""
303348

304-
api_key: str
305-
"""Public client-side API key for the environment. **INDEXED**."""
306-
id: DynamoInt
307-
"""Unique identifier for the environment in Core."""
308349

350+
class EnvironmentCompressed(
351+
_EnvironmentBaseFieldsCompressed,
352+
_EnvironmentV1Fields,
353+
_EnvironmentBaseFields,
354+
):
355+
"""Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
356+
Has compressed fields.
357+
358+
**DynamoDB table**: `flagsmith_environments`
359+
"""
309360

310-
class EnvironmentV2Meta(_EnvironmentFields):
361+
362+
class EnvironmentV2Meta(
363+
_EnvironmentBaseFieldsUncompressed,
364+
_EnvironmentV2MetaFields,
365+
_EnvironmentBaseFields,
366+
):
311367
"""Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
312368
313369
**DynamoDB table**: `flagsmith_environments_v2`
314370
"""
315371

316-
environment_id: str
317-
"""Unique identifier for the environment in Core. Same as `Environment.id`, but string-typed to reduce coupling with Core's type definitions **INDEXED**."""
318-
environment_api_key: str
319-
"""Public client-side API key for the environment. **INDEXED**."""
320-
document_key: Literal["_META"]
321-
"""The fixed document key for the environment v2 document. Always `"_META"`. **INDEXED**."""
322372

323-
id: DynamoInt
324-
"""Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema."""
373+
class EnvironmentV2MetaCompressed(
374+
_EnvironmentBaseFieldsCompressed,
375+
_EnvironmentV2MetaFields,
376+
_EnvironmentBaseFields,
377+
):
378+
"""Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
379+
Has compressed fields.
380+
381+
**DynamoDB table**: `flagsmith_environments_v2`
382+
"""
325383

326384

327385
class EnvironmentV2IdentityOverride(TypedDict):

src/flagsmith_schemas/types.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
from decimal import Decimal
2-
from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias
2+
from typing import (
3+
TYPE_CHECKING,
4+
Annotated,
5+
Any,
6+
Generic,
7+
Literal,
8+
SupportsBytes,
9+
TypeAlias,
10+
TypeVar,
11+
get_args,
12+
)
313

414
from flagsmith_schemas.constants import PYDANTIC_INSTALLED
515

616
if PYDANTIC_INSTALLED:
7-
from pydantic import WithJsonSchema
17+
from pydantic import (
18+
GetCoreSchemaHandler,
19+
TypeAdapter,
20+
WithJsonSchema,
21+
)
22+
from pydantic_core import core_schema
823

924
from flagsmith_schemas.pydantic_types import (
1025
ValidateDecimalAsFloat,
@@ -13,6 +28,7 @@
1328
ValidateStrAsISODateTime,
1429
ValidateStrAsUUID,
1530
)
31+
from flagsmith_schemas.utils import json_gzip
1632
elif not TYPE_CHECKING:
1733
# This code runs at runtime when Pydantic is not installed.
1834
# 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:
2642
ValidateStrAsISODateTime = ...
2743
ValidateStrAsUUID = ...
2844

45+
T = TypeVar("T")
46+
47+
48+
class DynamoBinary(SupportsBytes):
49+
"""boto3's wrapper type for bytes stored in DynamoDB."""
50+
51+
value: bytes | bytearray
52+
53+
54+
class JsonGzipped(DynamoBinary, Generic[T]):
55+
"""A gzipped JSON blob representing a value of type `T`."""
56+
57+
if PYDANTIC_INSTALLED:
58+
59+
@classmethod
60+
def __get_pydantic_core_schema__(
61+
cls,
62+
source_type: "type[JsonGzipped[T]]",
63+
handler: GetCoreSchemaHandler,
64+
) -> core_schema.CoreSchema:
65+
_adapter: TypeAdapter[T] = TypeAdapter(get_args(source_type)[0])
66+
67+
def _validate_json_gzipped(data: Any) -> bytes:
68+
return json_gzip(_adapter.validate_python(data))
69+
70+
# We're returning bytes here for two reasons:
71+
# 1. boto3.dynamodb seems to expect bytes as input for Binary columns.
72+
# 2. We want to avoid having boto3 as a dependency.
73+
return core_schema.no_info_before_validator_function(
74+
_validate_json_gzipped,
75+
core_schema.bytes_schema(strict=False),
76+
)
77+
2978

3079
DynamoInt: TypeAlias = Annotated[Decimal, ValidateDecimalAsInt]
3180
"""An integer value stored in DynamoDB.

src/flagsmith_schemas/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import gzip
2+
import typing
3+
4+
import simplejson as json
5+
6+
7+
def json_gzip(value: typing.Any) -> bytes:
8+
return gzip.compress(
9+
json.dumps(
10+
value,
11+
separators=(",", ":"),
12+
sort_keys=True,
13+
).encode("utf-8"),
14+
mtime=0,
15+
)

tests/integration/flagsmith_schemas/data/flagsmith_environments.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,28 +257,28 @@
257257
"multivariate_feature_option": {
258258
"value": "baz"
259259
},
260-
"percentage_allocation": 30
260+
"percentage_allocation": 30.0
261261
},
262262
{
263263
"id": 3402,
264264
"multivariate_feature_option": {
265265
"value": "bar"
266266
},
267-
"percentage_allocation": 30
267+
"percentage_allocation": 30.0
268268
},
269269
{
270270
"id": 3405,
271271
"multivariate_feature_option": {
272272
"value": 1
273273
},
274-
"percentage_allocation": 0
274+
"percentage_allocation": 0.0
275275
},
276276
{
277277
"id": 3406,
278278
"multivariate_feature_option": {
279279
"value": true
280280
},
281-
"percentage_allocation": 0
281+
"percentage_allocation": 0.0
282282
}
283283
],
284284
"django_id": 78986,

0 commit comments

Comments
 (0)