flagsmith-common 3.3.0__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.3.0 → flagsmith_common-3.4.0}/PKG-INFO +21 -1
  2. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/README.md +20 -0
  3. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/pyproject.toml +3 -1
  4. flagsmith_common-3.4.0/src/common/lint_tests.py +279 -0
  5. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/dynamodb.py +7 -1
  6. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/LICENSE +0 -0
  7. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/__init__.py +0 -0
  8. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/__init__.py +0 -0
  9. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/app.py +0 -0
  10. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/cli/__init__.py +0 -0
  11. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/cli/healthcheck.py +0 -0
  12. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/constants.py +0 -0
  13. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/logging.py +0 -0
  14. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/main.py +0 -0
  15. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/management/__init__.py +0 -0
  16. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/management/commands/__init__.py +0 -0
  17. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/management/commands/docgen.py +0 -0
  18. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/management/commands/start.py +0 -0
  19. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/management/commands/waitfordb.py +0 -0
  20. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/metrics.py +0 -0
  21. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/middleware.py +0 -0
  22. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/templates/docgen-metrics.md +0 -0
  23. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/urls.py +0 -0
  24. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/utils.py +0 -0
  25. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/core/views.py +0 -0
  26. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/environments/permissions.py +0 -0
  27. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/features/__init__.py +0 -0
  28. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/features/multivariate/__init__.py +0 -0
  29. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/features/multivariate/serializers.py +0 -0
  30. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/features/serializers.py +0 -0
  31. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/features/versioning/__init__.py +0 -0
  32. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/features/versioning/serializers.py +0 -0
  33. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/__init__.py +0 -0
  34. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/conf.py +0 -0
  35. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/constants.py +0 -0
  36. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/logging.py +0 -0
  37. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/metrics.py +0 -0
  38. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/metrics_server.py +0 -0
  39. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/middleware.py +0 -0
  40. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/gunicorn/utils.py +0 -0
  41. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/migrations/__init__.py +0 -0
  42. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/migrations/helpers/__init__.py +0 -0
  43. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  44. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/organisations/permissions.py +0 -0
  45. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/projects/permissions.py +0 -0
  46. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/prometheus/__init__.py +0 -0
  47. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/prometheus/utils.py +0 -0
  48. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/py.typed +0 -0
  49. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/test_tools/__init__.py +0 -0
  50. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/test_tools/plugin.py +0 -0
  51. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/test_tools/types.py +0 -0
  52. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/test_tools/utils.py +0 -0
  53. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/common/types.py +0 -0
  54. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/__init__.py +0 -0
  55. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/api.py +0 -0
  56. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/constants.py +0 -0
  57. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/py.typed +0 -0
  58. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  59. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/types.py +0 -0
  60. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/utils.py +0 -0
  61. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/flagsmith_schemas/validators.py +0 -0
  62. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/__init__.py +0 -0
  63. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/admin.py +0 -0
  64. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/apps.py +0 -0
  65. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/decorators.py +0 -0
  66. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/exceptions.py +0 -0
  67. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/health.py +0 -0
  68. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/managers.py +0 -0
  69. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/metrics.py +0 -0
  70. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0001_initial.py +0 -0
  71. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  72. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  73. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  74. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  75. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  76. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  77. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  78. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  79. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  80. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  81. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  82. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  83. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/__init__.py +0 -0
  84. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  85. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  86. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  87. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  88. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  89. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/migrations/sql/__init__.py +0 -0
  90. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/models.py +0 -0
  91. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/monitoring.py +0 -0
  92. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/processor.py +0 -0
  93. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/py.typed +0 -0
  94. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/routers.py +0 -0
  95. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/serializers.py +0 -0
  96. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/task_registry.py +0 -0
  97. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/task_run_method.py +0 -0
  98. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/tasks.py +0 -0
  99. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/threads.py +0 -0
  100. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/types.py +0 -0
  101. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/urls.py +0 -0
  102. {flagsmith_common-3.3.0 → flagsmith_common-3.4.0}/src/task_processor/utils.py +0 -0
  103. {flagsmith_common-3.3.0 → 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.3.0
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
@@ -81,6 +81,26 @@ This enables the `route` label for Prometheus HTTP metrics.
81
81
 
82
82
  5. To enable the `/metrics` endpoint, set the `PROMETHEUS_ENABLED` setting to `True`.
83
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
+
84
104
  ### Test tools
85
105
 
86
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.3.0"
3
+ version = "3.4.0"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -60,12 +60,14 @@ Repository = "https://github.com/flagsmith/flagsmith-common"
60
60
 
61
61
  [project.scripts]
62
62
  flagsmith = "common.core.main:main"
63
+ flagsmith-lint-tests = "common.lint_tests:main"
63
64
 
64
65
  [project.entry-points.pytest11]
65
66
  flagsmith-test-tools = "common.test_tools.plugin"
66
67
 
67
68
  [dependency-groups]
68
69
  dev = [
70
+ "diff-cover>=10.2.0",
69
71
  "dj-database-url (>=2.3.0, <3.0.0)",
70
72
  "django-stubs (>=5.1.3, <6.0.0)",
71
73
  "djangorestframework-stubs (>=3.15.3, <4.0.0)",
@@ -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())
@@ -210,7 +210,10 @@ class _EnvironmentBaseFields(TypedDict):
210
210
  """Last updated timestamp. If not set, current timestamp should be assumed."""
211
211
 
212
212
  allow_client_traits: NotRequired[bool]
213
- """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
+ """
214
217
  hide_sensitive_data: NotRequired[bool]
215
218
  """Whether the SDK API should hide sensitive data for this environment. Defaults to `False`."""
216
219
  hide_disabled_flags: NotRequired[bool | None]
@@ -401,3 +404,6 @@ class EnvironmentV2IdentityOverride(TypedDict):
401
404
  """The UUID for this identity, used by `edge-identities` APIs in Core. **INDEXED**."""
402
405
  feature_state: FeatureState
403
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
+ """