flagsmith-common 3.2.1__tar.gz → 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/PKG-INFO +22 -1
  2. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/README.md +20 -0
  3. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/pyproject.toml +4 -1
  4. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/templates/docgen-metrics.md +3 -1
  5. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/conf.py +11 -1
  6. flagsmith_common-3.4.0/src/common/gunicorn/metrics_server.py +65 -0
  7. flagsmith_common-3.4.0/src/common/lint_tests.py +279 -0
  8. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/dynamodb.py +86 -22
  9. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/types.py +51 -2
  10. flagsmith_common-3.4.0/src/flagsmith_schemas/utils.py +15 -0
  11. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/LICENSE +0 -0
  12. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/__init__.py +0 -0
  13. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/__init__.py +0 -0
  14. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/app.py +0 -0
  15. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/cli/__init__.py +0 -0
  16. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/cli/healthcheck.py +0 -0
  17. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/constants.py +0 -0
  18. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/logging.py +0 -0
  19. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/main.py +0 -0
  20. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/management/__init__.py +0 -0
  21. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/management/commands/__init__.py +0 -0
  22. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/management/commands/docgen.py +0 -0
  23. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/management/commands/start.py +0 -0
  24. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/management/commands/waitfordb.py +0 -0
  25. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/metrics.py +0 -0
  26. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/middleware.py +0 -0
  27. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/urls.py +0 -0
  28. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/utils.py +0 -0
  29. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/core/views.py +0 -0
  30. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/environments/permissions.py +0 -0
  31. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/features/__init__.py +0 -0
  32. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/features/multivariate/__init__.py +0 -0
  33. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/features/multivariate/serializers.py +0 -0
  34. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/features/serializers.py +0 -0
  35. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/features/versioning/__init__.py +0 -0
  36. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/features/versioning/serializers.py +0 -0
  37. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/__init__.py +0 -0
  38. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/constants.py +0 -0
  39. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/logging.py +0 -0
  40. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/metrics.py +0 -0
  41. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/middleware.py +0 -0
  42. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/gunicorn/utils.py +0 -0
  43. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/migrations/__init__.py +0 -0
  44. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/migrations/helpers/__init__.py +0 -0
  45. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  46. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/organisations/permissions.py +0 -0
  47. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/projects/permissions.py +0 -0
  48. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/prometheus/__init__.py +0 -0
  49. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/prometheus/utils.py +0 -0
  50. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/py.typed +0 -0
  51. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/test_tools/__init__.py +0 -0
  52. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/test_tools/plugin.py +0 -0
  53. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/test_tools/types.py +0 -0
  54. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/test_tools/utils.py +0 -0
  55. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/common/types.py +0 -0
  56. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/__init__.py +0 -0
  57. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/api.py +0 -0
  58. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/constants.py +0 -0
  59. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/py.typed +0 -0
  60. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  61. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/flagsmith_schemas/validators.py +0 -0
  62. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/__init__.py +0 -0
  63. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/admin.py +0 -0
  64. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/apps.py +0 -0
  65. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/decorators.py +0 -0
  66. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/exceptions.py +0 -0
  67. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/health.py +0 -0
  68. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/managers.py +0 -0
  69. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/metrics.py +0 -0
  70. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0001_initial.py +0 -0
  71. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  72. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  73. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  74. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  75. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  76. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  77. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  78. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  79. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  80. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  81. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  82. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  83. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/__init__.py +0 -0
  84. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  85. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  86. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  87. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  88. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  89. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/__init__.py +0 -0
  90. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/models.py +0 -0
  91. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/monitoring.py +0 -0
  92. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/processor.py +0 -0
  93. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/py.typed +0 -0
  94. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/routers.py +0 -0
  95. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/serializers.py +0 -0
  96. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/task_registry.py +0 -0
  97. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/task_run_method.py +0 -0
  98. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/tasks.py +0 -0
  99. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/threads.py +0 -0
  100. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/types.py +0 -0
  101. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/urls.py +0 -0
  102. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/utils.py +0 -0
  103. {flagsmith_common-3.2.1 → flagsmith_common-3.4.0}/src/task_processor/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-common
3
- Version: 3.2.1
3
+ Version: 3.4.0
4
4
  Summary: Flagsmith's common library
5
5
  Author: Matthew Elwell, Gagan Trivedi, Kim Gustyr, Zach Aysan, Francesco Lo Franco, Rodrigo López Dato, Evandro Myller, Wadii Zaim
6
6
  License-Expression: BSD-3-Clause
@@ -21,6 +21,7 @@ Requires-Dist: prometheus-client>=0.0.16 ; extra == 'common-core'
21
21
  Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'common-core'
22
22
  Requires-Dist: requests ; extra == 'common-core'
23
23
  Requires-Dist: simplejson>=3,<4 ; extra == 'common-core'
24
+ Requires-Dist: simplejson ; extra == 'flagsmith-schemas'
24
25
  Requires-Dist: typing-extensions ; extra == 'flagsmith-schemas'
25
26
  Requires-Dist: flagsmith-flag-engine>6 ; extra == 'flagsmith-schemas'
26
27
  Requires-Dist: backoff>=2.2.1,<3.0.0 ; extra == 'task-processor'
@@ -80,6 +81,26 @@ This enables the `route` label for Prometheus HTTP metrics.
80
81
 
81
82
  5. To enable the `/metrics` endpoint, set the `PROMETHEUS_ENABLED` setting to `True`.
82
83
 
84
+ ### Pre-commit hooks
85
+
86
+ This repo provides a [`flagsmith-lint-tests`](.pre-commit-hooks.yaml) hook that enforces test conventions:
87
+
88
+ - **FT001**: No module-level `Test*` classes — use function-based tests
89
+ - **FT002**: No `import unittest` / `from unittest import TestCase` — use pytest (`unittest.mock` is fine)
90
+ - **FT003**: Test names must follow `test_{subject}__{condition}__{expected}`
91
+ - **FT004**: Test bodies must contain `# Given`, `# When`, and `# Then` comments
92
+
93
+ To use in your repo, add to `.pre-commit-config.yaml`:
94
+
95
+ ```yaml
96
+ - repo: https://github.com/Flagsmith/flagsmith-common
97
+ rev: main
98
+ hooks:
99
+ - id: flagsmith-lint-tests
100
+ ```
101
+
102
+ Use `# noqa: FT003` (or any code) inline to suppress individual violations.
103
+
83
104
  ### Test tools
84
105
 
85
106
  #### Fixtures
@@ -35,6 +35,26 @@ This enables the `route` label for Prometheus HTTP metrics.
35
35
 
36
36
  5. To enable the `/metrics` endpoint, set the `PROMETHEUS_ENABLED` setting to `True`.
37
37
 
38
+ ### Pre-commit hooks
39
+
40
+ This repo provides a [`flagsmith-lint-tests`](.pre-commit-hooks.yaml) hook that enforces test conventions:
41
+
42
+ - **FT001**: No module-level `Test*` classes — use function-based tests
43
+ - **FT002**: No `import unittest` / `from unittest import TestCase` — use pytest (`unittest.mock` is fine)
44
+ - **FT003**: Test names must follow `test_{subject}__{condition}__{expected}`
45
+ - **FT004**: Test bodies must contain `# Given`, `# When`, and `# Then` comments
46
+
47
+ To use in your repo, add to `.pre-commit-config.yaml`:
48
+
49
+ ```yaml
50
+ - repo: https://github.com/Flagsmith/flagsmith-common
51
+ rev: main
52
+ hooks:
53
+ - id: flagsmith-lint-tests
54
+ ```
55
+
56
+ Use `# noqa: FT003` (or any code) inline to suppress individual violations.
57
+
38
58
  ### Test tools
39
59
 
40
60
  #### Fixtures
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.2.1"
3
+ version = "3.4.0"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -26,6 +26,7 @@ optional-dependencies = { test-tools = [
26
26
  "django-health-check",
27
27
  "prometheus-client (>=0.0.16)",
28
28
  ], flagsmith-schemas = [
29
+ "simplejson",
29
30
  "typing_extensions",
30
31
  "flagsmith-flag-engine>6",
31
32
  ] }
@@ -59,12 +60,14 @@ Repository = "https://github.com/flagsmith/flagsmith-common"
59
60
 
60
61
  [project.scripts]
61
62
  flagsmith = "common.core.main:main"
63
+ flagsmith-lint-tests = "common.lint_tests:main"
62
64
 
63
65
  [project.entry-points.pytest11]
64
66
  flagsmith-test-tools = "common.test_tools.plugin"
65
67
 
66
68
  [dependency-groups]
67
69
  dev = [
70
+ "diff-cover>=10.2.0",
68
71
  "dj-database-url (>=2.3.0, <3.0.0)",
69
72
  "django-stubs (>=5.1.3, <6.0.0)",
70
73
  "djangorestframework-stubs (>=3.15.3, <4.0.0)",
@@ -6,7 +6,9 @@ sidebar_position: 20
6
6
 
7
7
  ## Prometheus
8
8
 
9
- To enable the Prometheus `/metrics` endpoint, set the `PROMETHEUS_ENABLED` environment variable to `true`.
9
+ To enable the Prometheus `/metrics` endpoint, set the `PROMETHEUS_ENABLED` environment variable to `true`.
10
+
11
+ When enabled, Flagsmith serves the `/metrics` endpoint on port 9100.
10
12
 
11
13
  The metrics provided by Flagsmith are described below.
12
14
 
@@ -4,6 +4,7 @@ This module is used as a default configuration file for Gunicorn.
4
4
  It is used to correctly support Prometheus metrics in a multi-process environment.
5
5
  """
6
6
 
7
+ import os
7
8
  import typing
8
9
 
9
10
  from prometheus_client.multiprocess import mark_process_dead
@@ -13,6 +14,15 @@ if typing.TYPE_CHECKING: # pragma: no cover
13
14
  from gunicorn.workers.base import Worker # type: ignore[import-untyped]
14
15
 
15
16
 
16
- def worker_exit(server: "Arbiter", worker: "Worker") -> None:
17
+ def when_ready(server: "Arbiter") -> None:
18
+ """Start the standalone Prometheus metrics server after Gunicorn is ready."""
19
+ prometheus_enabled = os.getenv("PROMETHEUS_ENABLED", "")
20
+ if prometheus_enabled.lower() == "true": # Django settings are not available
21
+ from common.gunicorn.metrics_server import start_metrics_server
22
+
23
+ start_metrics_server()
24
+
25
+
26
+ def child_exit(server: "Arbiter", worker: "Worker") -> None:
17
27
  """Detach the process Prometheus metrics collector when a worker exits."""
18
28
  mark_process_dead(worker.pid) # type: ignore[no-untyped-call]
@@ -0,0 +1,65 @@
1
+ """
2
+ Standalone Prometheus metrics HTTP server.
3
+
4
+ This module provides a separate HTTP server for Prometheus metrics,
5
+ independent of the main Gunicorn application server. This improves
6
+ metrics reliability under high API load.
7
+
8
+ The server runs in a daemon thread and serves metrics from the shared
9
+ PROMETHEUS_MULTIPROC_DIR directory.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ import threading
15
+
16
+ from prometheus_client import CollectorRegistry, start_http_server
17
+ from prometheus_client.multiprocess import MultiProcessCollector
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ METRICS_SERVER_PORT = 9100
22
+
23
+ _server_started = False
24
+ _server_lock = threading.Lock()
25
+
26
+
27
+ def get_multiprocess_registry() -> CollectorRegistry:
28
+ """Create a registry configured for multiprocess metric collection."""
29
+ registry = CollectorRegistry()
30
+ MultiProcessCollector(registry) # type: ignore[no-untyped-call]
31
+ return registry
32
+
33
+
34
+ def start_metrics_server(
35
+ port: int = METRICS_SERVER_PORT,
36
+ ) -> None:
37
+ """
38
+ Start the standalone Prometheus metrics HTTP server.
39
+
40
+ This function is idempotent - calling it multiple times will only
41
+ start one server. The server runs in a daemon thread.
42
+
43
+ Args:
44
+ port: The port to serve metrics on. Defaults to 9100.
45
+ """
46
+ global _server_started
47
+
48
+ with _server_lock:
49
+ if _server_started:
50
+ logger.debug("Metrics server already started")
51
+ return
52
+
53
+ prometheus_multiproc_dir = os.environ.get("PROMETHEUS_MULTIPROC_DIR")
54
+ if not prometheus_multiproc_dir:
55
+ logger.warning("PROMETHEUS_MULTIPROC_DIR not set, skipping metrics server")
56
+ return
57
+
58
+ registry = get_multiprocess_registry()
59
+
60
+ try:
61
+ start_http_server(port=port, registry=registry)
62
+ _server_started = True
63
+ logger.info("Prometheus metrics server started on port %d", port)
64
+ except OSError as e:
65
+ logger.error("Failed to start metrics server on port %d: %s", port, e)
@@ -0,0 +1,279 @@
1
+ """Linter for Flagsmith test conventions.
2
+
3
+ Enforces:
4
+ - FT001: No module-level class Test* (function-only tests)
5
+ - FT002: No `import unittest` / `from unittest import TestCase` (unittest.mock is fine)
6
+ - FT003: Test name must have exactly 2 `__` separators: test_{subject}__{condition}__{expected}
7
+ - FT004: Test body must contain # Given, # When, and # Then comments
8
+
9
+ Output format matches ruff/flake8/mypy: {file}:{line}:{col}: {code} {message}
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import ast
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import NamedTuple
20
+
21
+ UNITTEST_BANNED_IMPORTS = frozenset(
22
+ {"TestCase", "TestSuite", "TestLoader", "TextTestRunner"}
23
+ )
24
+
25
+
26
+ class Violation(NamedTuple):
27
+ file: str
28
+ line: int
29
+ col: int
30
+ code: str
31
+ message: str
32
+
33
+ def __str__(self) -> str:
34
+ return f"{self.file}:{self.line}:{self.col}: {self.code} {self.message}"
35
+
36
+
37
+ def _has_fixture_decorator(node: ast.FunctionDef) -> bool:
38
+ for decorator in node.decorator_list:
39
+ if isinstance(decorator, ast.Attribute) and decorator.attr == "fixture":
40
+ return True
41
+ if isinstance(decorator, ast.Name) and decorator.id == "fixture":
42
+ return True
43
+ # Handle @pytest.fixture(...)
44
+ if (
45
+ isinstance(decorator, ast.Call)
46
+ and isinstance(decorator.func, ast.Attribute)
47
+ and decorator.func.attr == "fixture"
48
+ ):
49
+ return True
50
+ if (
51
+ isinstance(decorator, ast.Call)
52
+ and isinstance(decorator.func, ast.Name)
53
+ and decorator.func.id == "fixture"
54
+ ):
55
+ return True
56
+ return False
57
+
58
+
59
+ _COMMENT_RE = re.compile(r"#(.*)$")
60
+
61
+
62
+ def _extract_comments(source: str) -> dict[int, str]:
63
+ """Return a mapping of line number (1-based) -> comment text."""
64
+ comments: dict[int, str] = {}
65
+ for lineno, line in enumerate(source.splitlines(), start=1):
66
+ match = _COMMENT_RE.search(line)
67
+ if match:
68
+ comments[lineno] = "#" + match.group(1)
69
+ return comments
70
+
71
+
72
+ _NOQA_RE = re.compile(r"#\s*noqa\b(?::\s*(?P<codes>[A-Z0-9,\s]+))?")
73
+
74
+
75
+ def _is_noqa_suppressed(comment: str, code: str) -> bool:
76
+ """Check if a comment contains a noqa directive that suppresses the given code."""
77
+ match = _NOQA_RE.search(comment)
78
+ if not match:
79
+ return False
80
+ codes_str = match.group("codes")
81
+ # Bare noqa (without specific codes) suppresses everything
82
+ if codes_str is None:
83
+ return True
84
+ codes = {c.strip() for c in codes_str.split(",")}
85
+ return code in codes
86
+
87
+
88
+ def check_ft001(tree: ast.Module, filepath: str) -> list[Violation]:
89
+ """FT001: Module-level class Test* detected."""
90
+ violations = []
91
+ for node in ast.iter_child_nodes(tree):
92
+ if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
93
+ violations.append(
94
+ Violation(
95
+ file=filepath,
96
+ line=node.lineno,
97
+ col=node.col_offset + 1,
98
+ code="FT001",
99
+ message=f"Module-level test class `{node.name}` detected; use function-based tests",
100
+ )
101
+ )
102
+ return violations
103
+
104
+
105
+ def check_ft002(tree: ast.Module, filepath: str) -> list[Violation]:
106
+ """FT002: import unittest / from unittest import TestCase etc. (NOT unittest.mock)."""
107
+ violations = []
108
+ for node in ast.walk(tree):
109
+ if isinstance(node, ast.Import):
110
+ for alias in node.names:
111
+ # Flag `import unittest` but not `import unittest.mock`
112
+ if alias.name == "unittest":
113
+ violations.append(
114
+ Violation(
115
+ file=filepath,
116
+ line=node.lineno,
117
+ col=node.col_offset + 1,
118
+ code="FT002",
119
+ message="`import unittest` is not allowed; use pytest instead",
120
+ )
121
+ )
122
+ elif isinstance(node, ast.ImportFrom):
123
+ if node.module == "unittest":
124
+ for alias in node.names:
125
+ if alias.name in UNITTEST_BANNED_IMPORTS:
126
+ violations.append(
127
+ Violation(
128
+ file=filepath,
129
+ line=node.lineno,
130
+ col=node.col_offset + 1,
131
+ code="FT002",
132
+ message=f"`from unittest import {alias.name}` is not allowed; use pytest instead",
133
+ )
134
+ )
135
+ return violations
136
+
137
+
138
+ def check_ft003(tree: ast.Module, filepath: str) -> list[Violation]:
139
+ """FT003: Test name doesn't follow test_{subject}__{condition}__{expected} convention."""
140
+ violations = []
141
+ for node in ast.iter_child_nodes(tree):
142
+ if (
143
+ isinstance(node, ast.FunctionDef)
144
+ and node.name.startswith("test_")
145
+ and not _has_fixture_decorator(node)
146
+ ):
147
+ # Strip `test_` prefix and count `__` separators
148
+ after_prefix = node.name[5:]
149
+ parts = after_prefix.split("__")
150
+ if len(parts) != 3:
151
+ violations.append(
152
+ Violation(
153
+ file=filepath,
154
+ line=node.lineno,
155
+ col=node.col_offset + 1,
156
+ code="FT003",
157
+ message=f"Test name `{node.name}` doesn't match `test_{{subject}}__{{condition}}__{{expected}}` (found {len(parts)} parts, expected 3)",
158
+ )
159
+ )
160
+ return violations
161
+
162
+
163
+ def _find_missing_gwt(func_comments: list[str]) -> list[str]:
164
+ """Return list of missing Given/When/Then keywords from comments."""
165
+ has_given = False
166
+ has_when = False
167
+ has_then = False
168
+ for text in func_comments:
169
+ normalized = text.lstrip("#").strip().lower()
170
+ if normalized.startswith("given"):
171
+ has_given = True
172
+ # "Given / When" satisfies both
173
+ if "when" in normalized:
174
+ has_when = True
175
+ if normalized.startswith("when"):
176
+ has_when = True
177
+ # "When / Then" satisfies both
178
+ if "then" in normalized:
179
+ has_then = True
180
+ if normalized.startswith("then"):
181
+ has_then = True
182
+
183
+ missing = []
184
+ if not has_given:
185
+ missing.append("Given")
186
+ if not has_when:
187
+ missing.append("When")
188
+ if not has_then:
189
+ missing.append("Then")
190
+ return missing
191
+
192
+
193
+ def check_ft004(
194
+ tree: ast.Module, filepath: str, comments: dict[int, str]
195
+ ) -> list[Violation]:
196
+ """FT004: Missing # Given, # When, or # Then comments in test body."""
197
+ violations = []
198
+ for node in ast.iter_child_nodes(tree):
199
+ if (
200
+ isinstance(node, ast.FunctionDef)
201
+ and node.name.startswith("test_")
202
+ and not _has_fixture_decorator(node)
203
+ ):
204
+ func_comments = [
205
+ text
206
+ for line_no, text in comments.items()
207
+ if node.lineno <= line_no <= (node.end_lineno or node.lineno)
208
+ ]
209
+ missing = _find_missing_gwt(func_comments)
210
+ if missing:
211
+ violations.append(
212
+ Violation(
213
+ file=filepath,
214
+ line=node.lineno,
215
+ col=node.col_offset + 1,
216
+ code="FT004",
217
+ message=f"Test `{node.name}` is missing GWT comments: {', '.join(missing)}",
218
+ )
219
+ )
220
+ return violations
221
+
222
+
223
+ def lint_file(filepath: str) -> list[Violation]:
224
+ """Run all checks on a single file."""
225
+ path = Path(filepath)
226
+
227
+ # Only check test_*.py files
228
+ if not (path.name.startswith("test_") and path.suffix == ".py"):
229
+ return []
230
+
231
+ source = path.read_text(encoding="utf-8")
232
+ try:
233
+ tree = ast.parse(source, filename=filepath)
234
+ except SyntaxError:
235
+ return [
236
+ Violation(
237
+ file=filepath,
238
+ line=1,
239
+ col=1,
240
+ code="FT000",
241
+ message="Could not parse file (SyntaxError)",
242
+ )
243
+ ]
244
+
245
+ comments = _extract_comments(source)
246
+
247
+ violations = []
248
+ violations.extend(check_ft001(tree, filepath))
249
+ violations.extend(check_ft002(tree, filepath))
250
+ violations.extend(check_ft003(tree, filepath))
251
+ violations.extend(check_ft004(tree, filepath, comments))
252
+
253
+ # Filter out violations suppressed by noqa comments
254
+ return [
255
+ v
256
+ for v in violations
257
+ if v.line not in comments or not _is_noqa_suppressed(comments[v.line], v.code)
258
+ ]
259
+
260
+
261
+ def main(argv: list[str] | None = None) -> int:
262
+ parser = argparse.ArgumentParser(
263
+ description="Lint Flagsmith test conventions",
264
+ )
265
+ parser.add_argument("files", nargs="*", help="Files to check")
266
+ args = parser.parse_args(argv)
267
+
268
+ has_errors = False
269
+ for filepath in args.files:
270
+ violations = lint_file(filepath)
271
+ for v in violations:
272
+ has_errors = True
273
+ print(v)
274
+
275
+ return 1 if has_errors else 0
276
+
277
+
278
+ if __name__ == "__main__": # pragma: no cover
279
+ sys.exit(main())
@@ -20,6 +20,7 @@ from flagsmith_schemas.types import (
20
20
  DynamoFloat,
21
21
  DynamoInt,
22
22
  FeatureType,
23
+ JsonGzipped,
23
24
  UUIDStr,
24
25
  )
25
26
 
@@ -200,7 +201,7 @@ class Webhook(TypedDict):
200
201
  """Secret used to sign webhook payloads."""
201
202
 
202
203
 
203
- class _EnvironmentFields(TypedDict):
204
+ class _EnvironmentBaseFields(TypedDict):
204
205
  """Common fields for Environment documents."""
205
206
 
206
207
  name: NotRequired[str]
@@ -208,13 +209,11 @@ class _EnvironmentFields(TypedDict):
208
209
  updated_at: NotRequired[DateTimeStr | None]
209
210
  """Last updated timestamp. If not set, current timestamp should be assumed."""
210
211
 
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
-
216
212
  allow_client_traits: NotRequired[bool]
217
- """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`."""
213
+ """Whether the SDK API should allow clients to set traits for this environment.
214
+ If set to `False`, assumes only persisted traits and traits from server-side SDKs are used in evaluation, and traits incoming from client-side SDKs are ignored.
215
+ Defaults to `True`.
216
+ """
218
217
  hide_sensitive_data: NotRequired[bool]
219
218
  """Whether the SDK API should hide sensitive data for this environment. Defaults to `False`."""
220
219
  hide_disabled_flags: NotRequired[bool | None]
@@ -240,7 +239,52 @@ class _EnvironmentFields(TypedDict):
240
239
  """Webhook configuration."""
241
240
 
242
241
 
243
- ### Root document schemas below. Indexed fields are marked as **INDEXED** in the docstrings. ###
242
+ class _EnvironmentV1Fields(TypedDict):
243
+ """Common fields for environment documents in `flagsmith_environments`."""
244
+
245
+ api_key: str
246
+ """Public client-side API key for the environment. **INDEXED**."""
247
+ id: DynamoInt
248
+ """Unique identifier for the environment in Core."""
249
+
250
+
251
+ class _EnvironmentV2MetaFields(TypedDict):
252
+ """Common fields for environment documents in `flagsmith_environments_v2`."""
253
+
254
+ environment_id: str
255
+ """Unique identifier for the environment in Core. Same as `Environment.id`, but string-typed to reduce coupling with Core's type definitions **INDEXED**."""
256
+ environment_api_key: str
257
+ """Public client-side API key for the environment. **INDEXED**."""
258
+ document_key: Literal["_META"]
259
+ """The fixed document key for the environment v2 document. Always `"_META"`. **INDEXED**."""
260
+
261
+ id: DynamoInt
262
+ """Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema."""
263
+
264
+
265
+ class _EnvironmentBaseFieldsUncompressed(TypedDict):
266
+ """Common fields for uncompressed environment documents."""
267
+
268
+ project: Project
269
+ """Project-specific data for this environment."""
270
+ feature_states: list[FeatureState]
271
+ """List of feature states representing the environment defaults."""
272
+ compressed: NotRequired[Literal[False]]
273
+ """Either `False` or absent to indicate the data is uncompressed."""
274
+
275
+
276
+ class _EnvironmentBaseFieldsCompressed(TypedDict):
277
+ """Common fields for compressed environment documents."""
278
+
279
+ project: JsonGzipped[Project]
280
+ """Project-specific data for this environment. **COMPRESSED**."""
281
+ feature_states: JsonGzipped[list[FeatureState]]
282
+ """List of feature states representing the environment defaults. **COMPRESSED**."""
283
+ compressed: Literal[True]
284
+ """Always `True` to indicate the data is compressed."""
285
+
286
+
287
+ ### Root document schemas below. Indexed fields are marked as **INDEXED** in the docstrings. Compressed fields are marked as **COMPRESSED**. ###
244
288
 
245
289
 
246
290
  class EnvironmentAPIKey(TypedDict):
@@ -295,33 +339,50 @@ class Identity(TypedDict):
295
339
  """Unique identifier for the identity in Core. If identity created via Core's `edge-identities` API, this can be missing or `None`."""
296
340
 
297
341
 
298
- class Environment(_EnvironmentFields):
342
+ class Environment(
343
+ _EnvironmentBaseFieldsUncompressed,
344
+ _EnvironmentV1Fields,
345
+ _EnvironmentBaseFields,
346
+ ):
299
347
  """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
300
348
 
301
349
  **DynamoDB table**: `flagsmith_environments`
302
350
  """
303
351
 
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."""
352
+
353
+ class EnvironmentCompressed(
354
+ _EnvironmentBaseFieldsCompressed,
355
+ _EnvironmentV1Fields,
356
+ _EnvironmentBaseFields,
357
+ ):
358
+ """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
359
+ Has compressed fields.
360
+
361
+ **DynamoDB table**: `flagsmith_environments`
362
+ """
308
363
 
309
364
 
310
- class EnvironmentV2Meta(_EnvironmentFields):
365
+ class EnvironmentV2Meta(
366
+ _EnvironmentBaseFieldsUncompressed,
367
+ _EnvironmentV2MetaFields,
368
+ _EnvironmentBaseFields,
369
+ ):
311
370
  """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
312
371
 
313
372
  **DynamoDB table**: `flagsmith_environments_v2`
314
373
  """
315
374
 
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**."""
322
375
 
323
- id: DynamoInt
324
- """Unique identifier for the environment in Core. Exists for compatibility with the API environment document schema."""
376
+ class EnvironmentV2MetaCompressed(
377
+ _EnvironmentBaseFieldsCompressed,
378
+ _EnvironmentV2MetaFields,
379
+ _EnvironmentBaseFields,
380
+ ):
381
+ """Represents a Flagsmith environment. Carries all necessary data for flag evaluation within the environment.
382
+ Has compressed fields.
383
+
384
+ **DynamoDB table**: `flagsmith_environments_v2`
385
+ """
325
386
 
326
387
 
327
388
  class EnvironmentV2IdentityOverride(TypedDict):
@@ -343,3 +404,6 @@ class EnvironmentV2IdentityOverride(TypedDict):
343
404
  """The UUID for this identity, used by `edge-identities` APIs in Core. **INDEXED**."""
344
405
  feature_state: FeatureState
345
406
  """The feature state override for this identity."""
407
+ created_date: NotRequired[DateTimeStr]
408
+ """ISO 8601 creation timestamp. Note: might change between updates due to how it's written by Core.
409
+ """
@@ -1,10 +1,25 @@
1
1
  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
+ )
3
13
 
4
14
  from flagsmith_schemas.constants import PYDANTIC_INSTALLED
5
15
 
6
16
  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
8
23
 
9
24
  from flagsmith_schemas.pydantic_types import (
10
25
  ValidateDecimalAsFloat,
@@ -13,6 +28,7 @@ if PYDANTIC_INSTALLED:
13
28
  ValidateStrAsISODateTime,
14
29
  ValidateStrAsUUID,
15
30
  )
31
+ from flagsmith_schemas.utils import json_gzip
16
32
  elif not TYPE_CHECKING:
17
33
  # This code runs at runtime when Pydantic is not installed.
18
34
  # We could use PEP 649 strings with `Annotated`, but Pydantic is inconsistent in how it parses them.
@@ -26,6 +42,39 @@ elif not TYPE_CHECKING:
26
42
  ValidateStrAsISODateTime = ...
27
43
  ValidateStrAsUUID = ...
28
44
 
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
+
29
78
 
30
79
  DynamoInt: TypeAlias = Annotated[Decimal, ValidateDecimalAsInt]
31
80
  """An integer value stored in DynamoDB.
@@ -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
+ )