simple-module-feature-flags 0.0.39__tar.gz → 0.0.41__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 (40) hide show
  1. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/PKG-INFO +4 -4
  2. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/deps.py +2 -1
  3. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/endpoints/api.py +4 -0
  4. simple_module_feature_flags-0.0.41/feature_flags/invalidation.py +17 -0
  5. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/module.py +13 -0
  6. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/service.py +29 -9
  7. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/package.json +1 -1
  8. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/pyproject.toml +4 -4
  9. simple_module_feature_flags-0.0.41/tests/test_feature_flags_commit_invalidation.py +78 -0
  10. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_feature_flags_service.py +10 -0
  11. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/.gitignore +0 -0
  12. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/LICENSE +0 -0
  13. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/README.md +0 -0
  14. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/__init__.py +0 -0
  15. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/constants.py +0 -0
  16. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/contracts/__init__.py +0 -0
  17. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/contracts/schemas.py +0 -0
  18. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/endpoints/__init__.py +0 -0
  19. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/endpoints/views.py +0 -0
  20. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/locales/de.json +0 -0
  21. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/locales/en.json +0 -0
  22. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/models.py +0 -0
  23. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/pages/Browse.tsx +0 -0
  24. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/pages/components/FlagTable.tsx +0 -0
  25. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/pages/components/ScopeCard.tsx +0 -0
  26. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/pages/components/TenantPicker.tsx +0 -0
  27. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
  28. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/pages/types.ts +0 -0
  29. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/py.typed +0 -0
  30. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/feature_flags/tenant_cleanup.py +0 -0
  31. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/conftest.py +0 -0
  32. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_feature_flags_api.py +0 -0
  33. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_feature_flags_audit_links.py +0 -0
  34. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_feature_flags_menu_section.py +0 -0
  35. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_feature_flags_tenancy.py +0 -0
  36. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_feature_flags_tenant_cleanup.py +0 -0
  37. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_flag_description_i18n.py +0 -0
  38. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests/test_store_outage_fallback.py +0 -0
  39. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tests-js/feature_flags_Browse.test.tsx +0 -0
  40. {simple_module_feature_flags-0.0.39 → simple_module_feature_flags-0.0.41}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.39
3
+ Version: 0.0.41
4
4
  Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.39
25
- Requires-Dist: simple-module-db==0.0.39
26
- Requires-Dist: simple-module-hosting==0.0.39
24
+ Requires-Dist: simple-module-core==0.0.41
25
+ Requires-Dist: simple-module-db==0.0.41
26
+ Requires-Dist: simple-module-hosting==0.0.41
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_feature_flags
@@ -14,9 +14,10 @@ from feature_flags.service import FeatureFlagService
14
14
 
15
15
 
16
16
  async def get_feature_flag_service(
17
+ request: Request,
17
18
  db: AsyncSession = Depends(get_db),
18
19
  ) -> FeatureFlagService:
19
- return FeatureFlagService(db)
20
+ return FeatureFlagService(db, app=request.app)
20
21
 
21
22
 
22
23
  async def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
@@ -70,6 +70,8 @@ async def set_override(
70
70
  await service.set_override(name, body.enabled, registry=registry)
71
71
  view = service.build_view(registry, name)
72
72
  assert view is not None # _ensure_registered already proved it exists
73
+ view.enabled = body.enabled
74
+ view.overridden = True
73
75
  return view
74
76
 
75
77
 
@@ -125,6 +127,8 @@ async def set_tenant_override(
125
127
  )
126
128
  view = service.build_view(registry, name, tenant_id=tenant_id)
127
129
  assert view is not None # _ensure_registered already proved it exists
130
+ view.enabled = body.enabled
131
+ view.overridden = True
128
132
  return view
129
133
 
130
134
 
@@ -0,0 +1,17 @@
1
+ """Re-read flag overrides after commit or a remote invalidation."""
2
+
3
+ from fastapi import FastAPI
4
+ from simple_module_core.invalidation import InvalidationBus
5
+ from simple_module_core.invalidation_refresh import InvalidationRefresh
6
+
7
+ from feature_flags.service import FeatureFlagService
8
+
9
+ CHANNEL = "feature_flags.overrides"
10
+
11
+
12
+ def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh:
13
+ async def reload(_key: str | None) -> None:
14
+ async with app.state.sm.db.session_factory() as db:
15
+ await FeatureFlagService(db).hydrate_registry(app.state.sm.feature_flags)
16
+
17
+ return InvalidationRefresh(bus, CHANNEL, reload)
@@ -30,6 +30,7 @@ from feature_flags.constants import (
30
30
 
31
31
  if TYPE_CHECKING:
32
32
  from simple_module_core.events import EventBus
33
+ from simple_module_core.invalidation import InvalidationBus
33
34
  from sqlalchemy.ext.asyncio import AsyncSession
34
35
 
35
36
  _logger = logging.getLogger(__name__)
@@ -75,6 +76,11 @@ class FeatureFlagsModule(ModuleBase):
75
76
 
76
77
  bus.subscribe_durable(TenantDeleted, purge_tenant_data)
77
78
 
79
+ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
80
+ from feature_flags.invalidation import subscribe
81
+
82
+ self._refresh = subscribe(bus, app)
83
+
78
84
  def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
79
85
  from feature_flags.endpoints.api import router as api
80
86
  from feature_flags.endpoints.views import router as views
@@ -157,3 +163,10 @@ class FeatureFlagsModule(ModuleBase):
157
163
  await service.hydrate_registry(sm.feature_flags)
158
164
  except Exception:
159
165
  _logger.exception("feature_flags.hydrate_failed — continuing with registry defaults")
166
+
167
+ if hasattr(self, "_refresh"):
168
+ self._refresh.start()
169
+
170
+ async def on_shutdown(self, app: FastAPI) -> None:
171
+ if hasattr(self, "_refresh"):
172
+ await self._refresh.stop()
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from collections.abc import Callable
11
11
 
12
+ from fastapi import FastAPI
12
13
  from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
13
14
  from sqlalchemy import select
14
15
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -60,8 +61,9 @@ class FeatureFlagService:
60
61
  extra DB hit per request.
61
62
  """
62
63
 
63
- def __init__(self, db: AsyncSession) -> None:
64
+ def __init__(self, db: AsyncSession, app: FastAPI | None = None) -> None:
64
65
  self.db = db
66
+ self.app = app
65
67
 
66
68
  async def list_overrides(self) -> list[FeatureFlagOverrideOut]:
67
69
  """Every persisted override across all scopes, ordered for stable display."""
@@ -179,7 +181,7 @@ class FeatureFlagService:
179
181
  existing.enabled = enabled
180
182
  await self.db.flush()
181
183
  if registry is not None:
182
- registry.set_override(name, enabled, tenant_id=_registry_tenant_id(scope, scope_id))
184
+ self._on_commit(registry, name, enabled, scope, scope_id)
183
185
  return FeatureFlagOverrideOut.model_validate(existing)
184
186
 
185
187
  async def clear_override(
@@ -195,16 +197,34 @@ class FeatureFlagService:
195
197
  return False
196
198
  await self.db.delete(existing)
197
199
  if registry is not None:
198
- registry.clear_override(name, tenant_id=_registry_tenant_id(scope, scope_id))
200
+ self._on_commit(registry, name, None, scope, scope_id)
199
201
  return True
200
202
 
201
203
  async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int:
202
204
  """Load every persisted override (system + tenant) into the registry at boot."""
203
205
  overrides = await self.list_overrides()
204
- for ovr in overrides:
205
- registry.set_override(
206
- ovr.name,
207
- ovr.enabled,
208
- tenant_id=_registry_tenant_id(ovr.scope, ovr.scope_id),
209
- )
206
+ registry.replace_overrides(
207
+ {o.name: o.enabled for o in overrides if o.scope == SCOPE_SYSTEM},
208
+ {(o.name, o.scope_id): o.enabled for o in overrides if o.scope == SCOPE_TENANT},
209
+ )
210
210
  return len(overrides)
211
+
212
+ def _on_commit(
213
+ self,
214
+ registry: FeatureFlagRegistry,
215
+ name: str,
216
+ enabled: bool | None,
217
+ scope: str,
218
+ scope_id: str,
219
+ ) -> None:
220
+ async def committed() -> None:
221
+ if self.app is not None:
222
+ from feature_flags.invalidation import CHANNEL
223
+
224
+ await self.app.state.sm.invalidation.publish(CHANNEL, key=name)
225
+ elif enabled is None:
226
+ registry.clear_override(name, tenant_id=_registry_tenant_id(scope, scope_id))
227
+ else:
228
+ registry.set_override(name, enabled, tenant_id=_registry_tenant_id(scope, scope_id))
229
+
230
+ self.db.on_commit(committed)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/feature_flags",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the FeatureFlags module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_feature_flags"
3
- version = "0.0.39"
3
+ version = "0.0.41"
4
4
  description = "Simple feature-flag module with per-tenant overrides and a consumer API for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.39",
25
- "simple_module_db==0.0.39",
26
- "simple_module_hosting==0.0.39",
24
+ "simple_module_core==0.0.41",
25
+ "simple_module_db==0.0.41",
26
+ "simple_module_hosting==0.0.41",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -0,0 +1,78 @@
1
+ """Flag changes must survive commit before reaching any worker's registry."""
2
+
3
+ import pytest
4
+ from feature_flags.models import FeatureFlagOverride
5
+ from feature_flags.service import FeatureFlagService
6
+ from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
7
+ from simple_module_core.invalidation import Invalidation
8
+ from simple_module_db.transaction import finalize_session, rollback_session
9
+
10
+
11
+ @pytest.mark.parametrize("commit", [False, True])
12
+ @pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
13
+ @pytest.mark.parametrize("clear", [False, True])
14
+ async def test_flag_mutation_waits_for_commit(db_session, commit, scope, scope_id, clear):
15
+ registry = FeatureFlagRegistry()
16
+ registry.add(FeatureFlagDefinition("switch"))
17
+ tenant_id = scope_id or None
18
+ if clear:
19
+ db_session.add(
20
+ FeatureFlagOverride(scope=scope, scope_id=scope_id, name="switch", enabled=True)
21
+ )
22
+ await db_session.commit()
23
+ registry.set_override("switch", True, tenant_id=tenant_id)
24
+ service = FeatureFlagService(db_session)
25
+ if clear:
26
+ await service.clear_override("switch", registry, scope, scope_id)
27
+ else:
28
+ await service.set_override("switch", True, registry, scope, scope_id)
29
+ assert registry.is_enabled("switch", tenant_id) is clear
30
+ if commit:
31
+ await finalize_session(db_session)
32
+ else:
33
+ await rollback_session(db_session)
34
+ assert registry.is_enabled("switch", tenant_id) is (clear != commit)
35
+
36
+
37
+ @pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
38
+ async def test_remote_flag_changes_and_deletions_reload(app, scope, scope_id):
39
+ registry = app.state.sm.feature_flags
40
+ registry.add(FeatureFlagDefinition("switch"))
41
+ factory = app.state.sm.db.session_factory
42
+ async with factory() as db:
43
+ row = FeatureFlagOverride(scope=scope, scope_id=scope_id, name="switch", enabled=True)
44
+ db.add(row)
45
+ await db.commit()
46
+ row_id = row.id
47
+ bus = app.state.sm.invalidation
48
+ message = Invalidation(channel="feature_flags.overrides", origin="other")
49
+ await bus.deliver(message.to_wire())
50
+ assert registry.is_enabled("switch", scope_id or None) is True
51
+ async with factory() as db:
52
+ await db.delete(await db.get(FeatureFlagOverride, row_id))
53
+ await db.commit()
54
+ await bus.deliver(message.to_wire())
55
+ assert registry.is_enabled("switch", scope_id or None) is False
56
+
57
+
58
+ @pytest.mark.parametrize("commit", [False, True])
59
+ @pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
60
+ async def test_flag_request_publishes_only_after_commit(app, commit, scope, scope_id):
61
+ from feature_flags.deps import get_feature_flag_service
62
+ from starlette.requests import Request
63
+
64
+ registry = app.state.sm.feature_flags
65
+ registry.add(FeatureFlagDefinition("switch"))
66
+ messages = []
67
+ app.state.sm.invalidation.subscribe("feature_flags.overrides", messages.append)
68
+ async with app.state.sm.db.session_factory() as db:
69
+ service = await get_feature_flag_service(Request({"type": "http", "app": app}), db)
70
+ await service.set_override("switch", True, registry, scope, scope_id)
71
+ assert registry.is_enabled("switch", scope_id or None) is False
72
+ assert messages == []
73
+ if commit:
74
+ await finalize_session(db)
75
+ else:
76
+ await rollback_session(db)
77
+ assert registry.is_enabled("switch", scope_id or None) is commit
78
+ assert len(messages) == int(commit)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from feature_flags.constants import SCOPE_TENANT
6
6
  from feature_flags.service import FeatureFlagService
7
7
  from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
8
+ from simple_module_db.transaction import finalize_session
8
9
  from sqlalchemy.ext.asyncio import AsyncSession
9
10
 
10
11
 
@@ -25,6 +26,7 @@ class TestFeatureFlagServiceSystem:
25
26
  assert result.name == "beta_ui"
26
27
  assert result.scope == "system"
27
28
  assert result.enabled is True
29
+ await finalize_session(db_session)
28
30
  assert reg.is_enabled("beta_ui") is True
29
31
 
30
32
  async def test_set_override_twice_updates_in_place(self, db_session: AsyncSession):
@@ -37,6 +39,7 @@ class TestFeatureFlagServiceSystem:
37
39
  rows = await svc.list_overrides()
38
40
  assert len(rows) == 1
39
41
  assert rows[0].enabled is False
42
+ await finalize_session(db_session)
40
43
  assert reg.is_enabled("beta_ui") is False
41
44
 
42
45
  async def test_clear_override_removes_row_and_reverts_registry(self, db_session: AsyncSession):
@@ -48,6 +51,7 @@ class TestFeatureFlagServiceSystem:
48
51
 
49
52
  assert cleared is True
50
53
  assert await svc.list_overrides() == []
54
+ await finalize_session(db_session)
51
55
  assert reg.is_enabled("beta_ui") is False # back to default
52
56
 
53
57
  async def test_clear_override_unknown_flag_is_noop(self, db_session: AsyncSession):
@@ -62,6 +66,7 @@ class TestFeatureFlagServiceSystem:
62
66
  )
63
67
  await svc.set_override("a.flag", True, registry=reg)
64
68
 
69
+ await finalize_session(db_session)
65
70
  views = await svc.list_flags(reg)
66
71
 
67
72
  by_name = {v.name: v for v in views}
@@ -82,6 +87,7 @@ class TestFeatureFlagServiceSystem:
82
87
  await svc.set_override("legacy", True, registry=reg_old)
83
88
 
84
89
  reg_new = _registry_with(("current", "", False))
90
+ await finalize_session(db_session)
85
91
  views = await svc.list_flags(reg_new)
86
92
 
87
93
  assert [v.name for v in views] == ["current"]
@@ -98,6 +104,7 @@ class TestFeatureFlagServiceTenant:
98
104
  rows = await svc.list_overrides()
99
105
  assert len(rows) == 2
100
106
  # tenant row beats system for that tenant
107
+ await finalize_session(db_session)
101
108
  assert reg.is_enabled("beta_ui", tenant_id="acme") is False
102
109
  # other tenants fall back to system
103
110
  assert reg.is_enabled("beta_ui", tenant_id="other") is True
@@ -115,6 +122,7 @@ class TestFeatureFlagServiceTenant:
115
122
  )
116
123
 
117
124
  assert cleared is True
125
+ await finalize_session(db_session)
118
126
  assert reg.is_enabled("beta_ui", tenant_id="acme") is False
119
127
  assert reg.is_enabled("beta_ui", tenant_id="globex") is True
120
128
 
@@ -124,6 +132,7 @@ class TestFeatureFlagServiceTenant:
124
132
  await svc.set_override("beta_ui", True, registry=reg) # system on
125
133
  await svc.set_override("beta_ui", False, registry=reg, scope=SCOPE_TENANT, scope_id="acme")
126
134
 
135
+ await finalize_session(db_session)
127
136
  views = await svc.list_flags(reg, tenant_id="acme")
128
137
 
129
138
  view = next(v for v in views if v.name == "beta_ui")
@@ -138,6 +147,7 @@ class TestFeatureFlagServiceTenant:
138
147
  reg = _registry_with(("beta_ui", "", False))
139
148
  await svc.set_override("beta_ui", True, registry=reg) # system on
140
149
 
150
+ await finalize_session(db_session)
141
151
  views = await svc.list_flags(reg, tenant_id="acme")
142
152
 
143
153
  view = next(v for v in views if v.name == "beta_ui")