Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ All notable changes to this project are documented in this file. The format is b
- `mail` follows the module layout: `mail.core.{constants,settings,state,data_compatibility}` and `mail.delivery.{sender,runtime}`. `mail.settings` and `mail.constants` still import, with a `DeprecationWarning`, until 0.0.46.
- `site_lock` follows the module layout: `site_lock.core.{constants,settings,state}` and `site_lock.web.{middleware,page,rate_limit}` (the unlock template moves with the page). `site_lock.settings` still imports, with a `DeprecationWarning`, until 0.0.46.
- `dashboard` follows the module layout: `dashboard.core.constants`, `dashboard.web.{endpoints,stats,doctor}`, and the page components move from `pages/components/` to `ui/components/`. Nothing outside the module imported the old paths, so no aliases remain.
- `feature_flags` follows the module layout: `feature_flags.core.{constants,service,invalidation,tenant_cleanup}`, `feature_flags.web.{deps,endpoints}`, and the page components and types move to `ui/`. `feature_flags.constants` still imports, with a `DeprecationWarning`, until 0.0.46.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ when a later module PR moves files.
| background_tasks | 49 | 83 | flat |
| branding | 13 | 20 | flat |
| dashboard | 2 | 0 | follows this layout |
| feature_flags | 8 | 9 | flat |
| feature_flags | 3 (+1 shim) | 0 | follows this layout |
| file_storage | 39 | 65 | flat |
| keycloak | 13 | 19 | flat |
| mail | 2 (+2 shims) | 0 | follows this layout |
Expand Down
2 changes: 1 addition & 1 deletion docs/modules/feature_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Once registered, the flag shows up at `/admin/feature-flags` in the admin UI. Un
## Reading a flag at request time

```python
from feature_flags.deps import FeatureFlagRegistryDep
from feature_flags.web.deps import FeatureFlagRegistryDep


@router.post("/checkout")
Expand Down
2 changes: 1 addition & 1 deletion framework/core/tests/test_sm003_page_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def test_resolves_constant_imported_from_sibling_file(self, tmp_path: Path
endpoints = src_dir / "endpoints"
endpoints.mkdir()
(endpoints / "views.py").write_text(
"from feature_flags.constants import PAGE_BROWSE\n"
"from feature_flags.core.constants import PAGE_BROWSE\n"
"async def view(inertia):\n"
" return await inertia.render(PAGE_BROWSE, {})\n"
)
Expand Down
5 changes: 0 additions & 5 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,6 @@
services.py settings.py shared_props.py tenant_branding.py tenant_definitions.py
""".split()
),
"feature_flags": frozenset(
"""
constants.py deps.py endpoints invalidation.py service.py tenant_cleanup.py
""".split()
),
"file_storage": frozenset(
"""
aggregates.py audit_labels.py backend_guard.py backends body_limits.py checksum.py
Expand Down
2 changes: 1 addition & 1 deletion modules/feature_flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Gate a route using the registry dependency:
```python
from fastapi import APIRouter, HTTPException

from feature_flags.deps import FeatureFlagRegistryDep
from feature_flags.web.deps import FeatureFlagRegistryDep

router = APIRouter()

Expand Down
53 changes: 7 additions & 46 deletions modules/feature_flags/feature_flags/constants.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,11 @@
"""Stable identifiers for the feature_flags module."""
"""Moved to :mod:`feature_flags.core.constants`; this alias goes in 0.0.46 (module layout)."""

PERM_FEATURE_FLAGS_VIEW = "feature_flags.view"
PERM_FEATURE_FLAGS_MANAGE = "feature_flags.manage"
# Shown as a heading in the role editor, so it follows the same sentence case
# as the menu label and the page title rather than reading "Feature Flags"
# beside them.
PERM_GROUP = "Feature flags"
from __future__ import annotations

# Prefix is load-bearing on SQLite, which has no per-module schemas.
TABLE_OVERRIDE = "feature_flags_override"
UQ_OVERRIDE_SCOPE_NAME = "uq_feature_flags_override_scope_scope_id_name"
import importlib
import sys

SCOPE_SYSTEM = "system"
SCOPE_TENANT = "tenant"
ALL_SCOPES = (SCOPE_SYSTEM, SCOPE_TENANT)
# Empty string, not NULL: PG treats NULLs as distinct in unique indexes,
# which would let two "system" rows for the same flag coexist.
SYSTEM_SCOPE_ID = ""
from simple_module_core.deprecation import deprecated_module

SCOPE_MAX_LENGTH = 10
SCOPE_ID_MAX_LENGTH = 64

LOCALE_NAMESPACE = "feature_flags"

MENU_LABEL = "Feature flags"
# Trailing slash: the browse route is registered at "/" under the view
# prefix, so linking to the bare prefix costs a 307 on every navigation.
MENU_URL = "/admin/feature-flags/"
MENU_ICON = "flag"
MENU_ORDER = 110

PAGE_BROWSE = "FeatureFlags/Browse"

API_PREFIX = "/api/feature_flags"
VIEW_PREFIX = "/admin/feature-flags"
QP_TENANT_ID = "tenant_id"
# Audit rows carry an override's primary key and nothing else. There is no
# per-override page to land on, so the link opens the flags screen and names
# the row it came from; an AuditLink without an "{id}" slot is rejected at
# boot because every row would otherwise resolve to the same URL.
QP_OVERRIDE = "override"
AUDIT_LINK_LABEL = "Feature flag override"
AUDIT_LINK_LABEL_KEY = "feature_flags.audit.override"

# Module name and browse URL of the audit log, used to decide whether the
# "View change history" link has anywhere to go and where. Not a dependency:
# flags work perfectly well without an audit log installed.
AUDIT_LOG_MODULE = "AuditLog"
AUDIT_LOG_VIEW_URL = "/admin/audit-log/"
deprecated_module("feature_flags.constants", remove_in="0.0.46", use="feature_flags.core.constants")
sys.modules[__name__] = importlib.import_module("feature_flags.core.constants")
1 change: 1 addition & 0 deletions modules/feature_flags/feature_flags/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""What every feature-flag feature builds on: constants, service, invalidation, tenant cleanup."""
50 changes: 50 additions & 0 deletions modules/feature_flags/feature_flags/core/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Stable identifiers for the feature_flags module."""

PERM_FEATURE_FLAGS_VIEW = "feature_flags.view"
PERM_FEATURE_FLAGS_MANAGE = "feature_flags.manage"
# Shown as a heading in the role editor, so it follows the same sentence case
# as the menu label and the page title rather than reading "Feature Flags"
# beside them.
PERM_GROUP = "Feature flags"

# Prefix is load-bearing on SQLite, which has no per-module schemas.
TABLE_OVERRIDE = "feature_flags_override"
UQ_OVERRIDE_SCOPE_NAME = "uq_feature_flags_override_scope_scope_id_name"

SCOPE_SYSTEM = "system"
SCOPE_TENANT = "tenant"
ALL_SCOPES = (SCOPE_SYSTEM, SCOPE_TENANT)
# Empty string, not NULL: PG treats NULLs as distinct in unique indexes,
# which would let two "system" rows for the same flag coexist.
SYSTEM_SCOPE_ID = ""

SCOPE_MAX_LENGTH = 10
SCOPE_ID_MAX_LENGTH = 64

LOCALE_NAMESPACE = "feature_flags"

MENU_LABEL = "Feature flags"
# Trailing slash: the browse route is registered at "/" under the view
# prefix, so linking to the bare prefix costs a 307 on every navigation.
MENU_URL = "/admin/feature-flags/"
MENU_ICON = "flag"
MENU_ORDER = 110

PAGE_BROWSE = "FeatureFlags/Browse"

API_PREFIX = "/api/feature_flags"
VIEW_PREFIX = "/admin/feature-flags"
QP_TENANT_ID = "tenant_id"
# Audit rows carry an override's primary key and nothing else. There is no
# per-override page to land on, so the link opens the flags screen and names
# the row it came from; an AuditLink without an "{id}" slot is rejected at
# boot because every row would otherwise resolve to the same URL.
QP_OVERRIDE = "override"
AUDIT_LINK_LABEL = "Feature flag override"
AUDIT_LINK_LABEL_KEY = "feature_flags.audit.override"

# Module name and browse URL of the audit log, used to decide whether the
# "View change history" link has anywhere to go and where. Not a dependency:
# flags work perfectly well without an audit log installed.
AUDIT_LOG_MODULE = "AuditLog"
AUDIT_LOG_VIEW_URL = "/admin/audit-log/"
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from simple_module_core.invalidation_refresh import InvalidationRefresh
from simple_module_core.tenant_events import TENANT_DELETED_CHANNEL

from feature_flags.service import FeatureFlagService
from feature_flags.core.service import FeatureFlagService

CHANNEL = "feature_flags.overrides"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col

from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID
from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView
from feature_flags.core.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID
from feature_flags.models import FeatureFlagOverride


Expand Down Expand Up @@ -219,7 +219,7 @@ def _on_commit(
) -> None:
async def committed() -> None:
if self.app is not None:
from feature_flags.invalidation import CHANNEL
from feature_flags.core.invalidation import CHANNEL

await self.app.state.sm.invalidation.publish(CHANNEL, key=name)
elif enabled is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from simple_module_core.tenant_events import TenantDeleted
from sqlmodel import col, delete

from feature_flags.constants import SCOPE_TENANT
from feature_flags.core.constants import SCOPE_TENANT
from feature_flags.models import FeatureFlagOverride


Expand Down
2 changes: 1 addition & 1 deletion modules/feature_flags/feature_flags/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from sqlalchemy import UniqueConstraint
from sqlmodel import Field

from feature_flags.constants import (
from feature_flags.core.constants import (
SCOPE_ID_MAX_LENGTH,
SCOPE_MAX_LENGTH,
SCOPE_SYSTEM,
Expand Down
12 changes: 6 additions & 6 deletions modules/feature_flags/feature_flags/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.permissions import PermissionRegistry

from feature_flags.constants import (
from feature_flags.core.constants import (
AUDIT_LINK_LABEL,
AUDIT_LINK_LABEL_KEY,
LOCALE_NAMESPACE,
Expand Down Expand Up @@ -72,7 +72,7 @@ class FeatureFlagsModule(ModuleBase):
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
from simple_module_core.tenant_events import TenantDeleted

from feature_flags.tenant_cleanup import purge_tenant_data
from feature_flags.core.tenant_cleanup import purge_tenant_data

bus.subscribe_durable(TenantDeleted, purge_tenant_data)
if app is not None:
Expand All @@ -83,13 +83,13 @@ async def forget_tenant(event: TenantDeleted) -> None:
bus.subscribe(TenantDeleted, forget_tenant)

def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
from feature_flags.invalidation import subscribe
from feature_flags.core.invalidation import subscribe

self._refresh = subscribe(bus, app)

def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
from feature_flags.endpoints.api import router as api
from feature_flags.endpoints.views import router as views
from feature_flags.web.endpoints.api import router as api
from feature_flags.web.endpoints.views import router as views

api_router.include_router(api)
view_router.include_router(views)
Expand Down Expand Up @@ -160,7 +160,7 @@ async def on_startup(self, app: FastAPI) -> None:
conservative choice — admins can re-toggle overrides once the
store is healthy again.
"""
from feature_flags.service import FeatureFlagService
from feature_flags.core.service import FeatureFlagService

sm = app.state.sm
try:
Expand Down
8 changes: 4 additions & 4 deletions modules/feature_flags/feature_flags/pages/Browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import { useState } from 'react';
import { toast } from 'sonner';
import { FlagTable } from './components/FlagTable';
import { ScopeCard } from './components/ScopeCard';
import { type PendingToggle, ToggleConfirmDialog } from './components/ToggleConfirmDialog';
import type { FeatureFlag } from './types';
import { FlagTable } from '../ui/components/FlagTable';
import { ScopeCard } from '../ui/components/ScopeCard';
import { type PendingToggle, ToggleConfirmDialog } from '../ui/components/ToggleConfirmDialog';
import type { FeatureFlag } from '../ui/types';

interface Props {
flags: FeatureFlag[];
Expand Down
1 change: 1 addition & 0 deletions modules/feature_flags/feature_flags/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The HTTP side: request deps and the REST and Inertia endpoints."""
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from simple_module_db.deps import get_db
from sqlalchemy.ext.asyncio import AsyncSession

from feature_flags.service import FeatureFlagService
from feature_flags.core.service import FeatureFlagService


async def get_feature_flag_service(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
from simple_module_core.feature_flags import FeatureFlagRegistry
from simple_module_hosting.permissions import RequiresPermission

from feature_flags.constants import (
from feature_flags.contracts.schemas import FeatureFlagView, ToggleRequest
from feature_flags.core.constants import (
PERM_FEATURE_FLAGS_MANAGE,
PERM_FEATURE_FLAGS_VIEW,
SCOPE_TENANT,
)
from feature_flags.contracts.schemas import FeatureFlagView, ToggleRequest
from feature_flags.deps import (
from feature_flags.web.deps import (
FeatureFlagRegistryDep,
FeatureFlagServiceDep,
require_known_tenant,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from simple_module_inertia import InertiaResponse
from starlette.responses import RedirectResponse

from feature_flags.constants import (
from feature_flags.core.constants import (
AUDIT_LOG_MODULE,
AUDIT_LOG_VIEW_URL,
MENU_URL,
Expand All @@ -27,7 +27,7 @@
SCOPE_TENANT,
SYSTEM_SCOPE_ID,
)
from feature_flags.deps import (
from feature_flags.web.deps import (
FeatureFlagRegistryDep,
FeatureFlagServiceDep,
require_known_tenant,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { toast } from 'sonner';
import Browse from '../feature_flags/pages/Browse';
import type { FeatureFlag } from '../feature_flags/pages/types';
import type { FeatureFlag } from '../feature_flags/ui/types';

const FLAGS: FeatureFlag[] = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Flag changes must survive commit before reaching any worker's registry."""

import pytest
from feature_flags.core.service import FeatureFlagService
from feature_flags.models import FeatureFlagOverride
from feature_flags.service import FeatureFlagService
from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
from simple_module_core.invalidation import Invalidation
from simple_module_db.transaction import finalize_session, rollback_session
Expand Down Expand Up @@ -58,7 +58,7 @@ async def test_remote_flag_changes_and_deletions_reload(app, scope, scope_id):
@pytest.mark.parametrize("commit", [False, True])
@pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
async def test_flag_request_publishes_only_after_commit(app, commit, scope, scope_id):
from feature_flags.deps import get_feature_flag_service
from feature_flags.web.deps import get_feature_flag_service
from starlette.requests import Request

registry = app.state.sm.feature_flags
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@
from types import SimpleNamespace

import httpx
from feature_flags.constants import (
from feature_flags.core.constants import (
AUDIT_LOG_MODULE,
AUDIT_LOG_VIEW_URL,
MENU_URL,
PAGE_BROWSE,
TABLE_OVERRIDE,
VIEW_PREFIX,
)
from feature_flags.endpoints.views import _audit_log_url
from feature_flags.models import FeatureFlagOverride
from feature_flags.module import FeatureFlagsModule
from feature_flags.web.endpoints.views import _audit_log_url
from simple_module_core.audit_links import AuditLinkRegistry


Expand Down Expand Up @@ -82,7 +82,7 @@ def test_label_key_resolves_in_the_shipped_catalog() -> None:
assert link is not None

catalog = json.loads(
(Path(__file__).resolve().parents[1] / "feature_flags/locales/en.json").read_text()
(Path(__file__).resolve().parents[3] / "feature_flags/locales/en.json").read_text()
)
namespace, *path = link.label_key.split(".")
assert namespace == "feature_flags"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from feature_flags.constants import MENU_URL
from feature_flags.core.constants import MENU_URL
from feature_flags.module import FeatureFlagsModule
from simple_module_core.menu import MenuRegistry, MenuSection

Expand All @@ -18,7 +18,7 @@ def test_feature_flags_menu_item_is_in_admin_sidebar() -> None:
def test_permission_group_matches_the_menu_label() -> None:
"""The role editor lists this group beside the sidebar entry it governs;
"Feature Flags" next to "Feature flags" reads as two different things."""
from feature_flags.constants import MENU_LABEL, PERM_GROUP
from feature_flags.core.constants import MENU_LABEL, PERM_GROUP
from simple_module_core.permissions import PermissionRegistry

registry = PermissionRegistry()
Expand Down
Loading
Loading