simple-module-feature-flags 0.0.38__tar.gz → 0.0.40__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.
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/PKG-INFO +4 -4
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/deps.py +2 -1
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/endpoints/api.py +4 -0
- simple_module_feature_flags-0.0.40/feature_flags/invalidation.py +17 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/module.py +21 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/service.py +33 -12
- simple_module_feature_flags-0.0.40/feature_flags/tenant_cleanup.py +18 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/package.json +1 -1
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/pyproject.toml +4 -4
- simple_module_feature_flags-0.0.40/tests/test_feature_flags_commit_invalidation.py +78 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_service.py +10 -0
- simple_module_feature_flags-0.0.40/tests/test_feature_flags_tenant_cleanup.py +34 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/.gitignore +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/LICENSE +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/README.md +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/__init__.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/constants.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/contracts/__init__.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/contracts/schemas.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/endpoints/__init__.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/endpoints/views.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/locales/de.json +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/locales/en.json +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/models.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/Browse.tsx +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/FlagTable.tsx +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/ScopeCard.tsx +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/TenantPicker.tsx +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/types.ts +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/py.typed +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/conftest.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_api.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_audit_links.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_menu_section.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_tenancy.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_flag_description_i18n.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_store_outage_fallback.py +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests-js/feature_flags_Browse.test.tsx +0 -0
- {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/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.
|
|
3
|
+
Version: 0.0.40
|
|
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.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.40
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.40
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.40
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# simple_module_feature_flags
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/deps.py
RENAMED
|
@@ -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)
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/module.py
RENAMED
|
@@ -29,6 +29,8 @@ from feature_flags.constants import (
|
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
|
+
from simple_module_core.events import EventBus
|
|
33
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
32
34
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
35
|
|
|
34
36
|
_logger = logging.getLogger(__name__)
|
|
@@ -67,6 +69,18 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
67
69
|
i18n_audience="admin",
|
|
68
70
|
)
|
|
69
71
|
|
|
72
|
+
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
|
|
73
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
74
|
+
|
|
75
|
+
from feature_flags.tenant_cleanup import purge_tenant_data
|
|
76
|
+
|
|
77
|
+
bus.subscribe_durable(TenantDeleted, purge_tenant_data)
|
|
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
|
+
|
|
70
84
|
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
71
85
|
from feature_flags.endpoints.api import router as api
|
|
72
86
|
from feature_flags.endpoints.views import router as views
|
|
@@ -149,3 +163,10 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
149
163
|
await service.hydrate_registry(sm.feature_flags)
|
|
150
164
|
except Exception:
|
|
151
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()
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/service.py
RENAMED
|
@@ -9,9 +9,11 @@ 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
|
|
16
|
+
from sqlmodel import col
|
|
15
17
|
|
|
16
18
|
from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID
|
|
17
19
|
from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView
|
|
@@ -59,8 +61,9 @@ class FeatureFlagService:
|
|
|
59
61
|
extra DB hit per request.
|
|
60
62
|
"""
|
|
61
63
|
|
|
62
|
-
def __init__(self, db: AsyncSession) -> None:
|
|
64
|
+
def __init__(self, db: AsyncSession, app: FastAPI | None = None) -> None:
|
|
63
65
|
self.db = db
|
|
66
|
+
self.app = app
|
|
64
67
|
|
|
65
68
|
async def list_overrides(self) -> list[FeatureFlagOverrideOut]:
|
|
66
69
|
"""Every persisted override across all scopes, ordered for stable display."""
|
|
@@ -151,9 +154,9 @@ class FeatureFlagService:
|
|
|
151
154
|
async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None:
|
|
152
155
|
result = await self.db.execute(
|
|
153
156
|
select(FeatureFlagOverride).where(
|
|
154
|
-
FeatureFlagOverride.scope == scope,
|
|
155
|
-
FeatureFlagOverride.scope_id == scope_id,
|
|
156
|
-
FeatureFlagOverride.name == name,
|
|
157
|
+
col(FeatureFlagOverride.scope) == scope,
|
|
158
|
+
col(FeatureFlagOverride.scope_id) == scope_id,
|
|
159
|
+
col(FeatureFlagOverride.name) == name,
|
|
157
160
|
)
|
|
158
161
|
)
|
|
159
162
|
return result.scalar_one_or_none()
|
|
@@ -178,7 +181,7 @@ class FeatureFlagService:
|
|
|
178
181
|
existing.enabled = enabled
|
|
179
182
|
await self.db.flush()
|
|
180
183
|
if registry is not None:
|
|
181
|
-
|
|
184
|
+
self._on_commit(registry, name, enabled, scope, scope_id)
|
|
182
185
|
return FeatureFlagOverrideOut.model_validate(existing)
|
|
183
186
|
|
|
184
187
|
async def clear_override(
|
|
@@ -194,16 +197,34 @@ class FeatureFlagService:
|
|
|
194
197
|
return False
|
|
195
198
|
await self.db.delete(existing)
|
|
196
199
|
if registry is not None:
|
|
197
|
-
|
|
200
|
+
self._on_commit(registry, name, None, scope, scope_id)
|
|
198
201
|
return True
|
|
199
202
|
|
|
200
203
|
async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int:
|
|
201
204
|
"""Load every persisted override (system + tenant) into the registry at boot."""
|
|
202
205
|
overrides = await self.list_overrides()
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
tenant_id=_registry_tenant_id(ovr.scope, ovr.scope_id),
|
|
208
|
-
)
|
|
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
|
+
)
|
|
209
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)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Durable cleanup of tenant-scoped feature flag overrides."""
|
|
2
|
+
|
|
3
|
+
from simple_module_core.durable_events import DurableContext
|
|
4
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
5
|
+
from sqlmodel import col, delete
|
|
6
|
+
|
|
7
|
+
from feature_flags.constants import SCOPE_TENANT
|
|
8
|
+
from feature_flags.models import FeatureFlagOverride
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def purge_tenant_data(event: TenantDeleted, ctx: DurableContext) -> None:
|
|
12
|
+
"""Keep system/other tenants' overrides; acknowledge with the worker transaction."""
|
|
13
|
+
await ctx.db.execute(
|
|
14
|
+
delete(FeatureFlagOverride).where(
|
|
15
|
+
col(FeatureFlagOverride.scope) == SCOPE_TENANT,
|
|
16
|
+
col(FeatureFlagOverride.scope_id) == event.tenant_id,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_feature_flags"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.40"
|
|
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.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.40",
|
|
25
|
+
"simple_module_db==0.0.40",
|
|
26
|
+
"simple_module_hosting==0.0.40",
|
|
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")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tenant deletion purges tenant overrides without touching other scopes."""
|
|
2
|
+
|
|
3
|
+
from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT
|
|
4
|
+
from feature_flags.models import FeatureFlagOverride
|
|
5
|
+
from feature_flags.module import FeatureFlagsModule
|
|
6
|
+
from simple_module_core.durable_events import DurableContext, event_key
|
|
7
|
+
from simple_module_core.events import EventBus
|
|
8
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
9
|
+
from sqlmodel import select
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def test_durable_tenant_cleanup_is_scoped_idempotent_and_transactional(db_session):
|
|
13
|
+
bus = EventBus()
|
|
14
|
+
FeatureFlagsModule().register_event_handlers(bus)
|
|
15
|
+
names = bus.durable.handler_names(TenantDeleted)
|
|
16
|
+
assert len(names) == 1
|
|
17
|
+
subscription = bus.durable.lookup(event_key(TenantDeleted), names[0])
|
|
18
|
+
assert subscription is not None
|
|
19
|
+
scopes = [(SCOPE_TENANT, "deleted"), (SCOPE_TENANT, "other"), (SCOPE_SYSTEM, "deleted")]
|
|
20
|
+
for scope, scope_id in scopes:
|
|
21
|
+
db_session.add(FeatureFlagOverride(scope=scope, scope_id=scope_id, name="test.flag"))
|
|
22
|
+
await db_session.commit()
|
|
23
|
+
event = TenantDeleted(tenant_id="deleted", slug="deleted", name="Deleted")
|
|
24
|
+
ctx = DurableContext(db=db_session, attempt=1)
|
|
25
|
+
await subscription.handler(event, ctx)
|
|
26
|
+
await db_session.rollback()
|
|
27
|
+
assert len((await db_session.execute(select(FeatureFlagOverride))).scalars().all()) == 3
|
|
28
|
+
await subscription.handler(event, ctx)
|
|
29
|
+
await subscription.handler(event, DurableContext(db=db_session, attempt=2))
|
|
30
|
+
rows = (await db_session.execute(select(FeatureFlagOverride))).scalars().all()
|
|
31
|
+
assert {(row.scope, row.scope_id) for row in rows} == {
|
|
32
|
+
(SCOPE_TENANT, "other"),
|
|
33
|
+
(SCOPE_SYSTEM, "deleted"),
|
|
34
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/__init__.py
RENAMED
|
File without changes
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|