Add profile-driven feature runtime utilities

- Add feature_registry.py with FeatureGroup definitions for core, podcast, youtube, content_planning
- Add feature_profiles.py to parse ALWRITY_FEATURE_TO_ENABLE env var
- Add feature_runtime.py with is_enabled(), get_enabled_routers() helpers
- Fix syntax error in __init__.py (duplicate OnboardingManager)

Enables feature toggles via ALWRITY_FEATURE_TO_ENABLE environment variable.
This commit is contained in:
ajaysi
2026-03-31 15:04:05 +05:30
parent bf980ab89b
commit 5ac1b9439d
4 changed files with 226 additions and 1 deletions

View File

@@ -12,6 +12,14 @@ from .rate_limiter import RateLimiter
from .frontend_serving import FrontendServing
from .router_manager import RouterManager
from .onboarding_manager import OnboardingManager
from .feature_runtime import (
get_active_profiles,
get_enabled_groups,
get_enabled_optional_services,
get_enabled_routers,
get_enabled_startup_hooks,
is_enabled,
)
__all__ = [
'DependencyManager',
@@ -22,5 +30,11 @@ __all__ = [
'RateLimiter',
'FrontendServing',
'RouterManager',
'OnboardingManager'
'OnboardingManager',
'get_active_profiles',
'get_enabled_groups',
'get_enabled_optional_services',
'get_enabled_routers',
'get_enabled_startup_hooks',
'is_enabled'
]

View File

@@ -0,0 +1,77 @@
"""Feature profile parsing and expansion logic."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Iterable, Tuple
from .feature_registry import FEATURE_GROUPS, PROFILE_GROUP_MAP
ENV_FEATURE_PROFILE = "ALWRITY_FEATURE_TO_ENABLE"
DEFAULT_PROFILE = "all"
@dataclass(frozen=True)
class ExpandedFeatureProfile:
"""Expanded profile data used by runtime helpers."""
profiles: Tuple[str, ...]
groups: Tuple[str, ...]
class UnknownFeatureProfileError(ValueError):
"""Raised when ALWRITY_FEATURE_TO_ENABLE contains unknown profile values."""
def _normalize_values(raw_value: str | None) -> Tuple[str, ...]:
if not raw_value or not raw_value.strip():
return (DEFAULT_PROFILE,)
normalized = tuple(
value.strip().lower()
for value in raw_value.split(",")
if value.strip()
)
return normalized or (DEFAULT_PROFILE,)
def parse_feature_profiles(raw_value: str | None = None) -> Tuple[str, ...]:
"""Parse and validate profile names from env/raw input.
Supports comma-separated profile names, e.g. `core,podcast`.
Raises UnknownFeatureProfileError when any profile is not registered.
"""
selected_profiles = _normalize_values(raw_value if raw_value is not None else os.getenv(ENV_FEATURE_PROFILE))
unknown = sorted({profile for profile in selected_profiles if profile not in PROFILE_GROUP_MAP})
if unknown:
supported = ", ".join(sorted(PROFILE_GROUP_MAP))
unknown_display = ", ".join(unknown)
raise UnknownFeatureProfileError(
f"Unknown {ENV_FEATURE_PROFILE} value(s): {unknown_display}. Supported profiles: {supported}."
)
return selected_profiles
def _dedupe_stable(items: Iterable[str]) -> Tuple[str, ...]:
return tuple(dict.fromkeys(items))
def expand_profiles(profiles: Tuple[str, ...]) -> ExpandedFeatureProfile:
"""Expand profile names into a deduplicated group list."""
groups = _dedupe_stable(
group
for profile in profiles
for group in PROFILE_GROUP_MAP[profile]
)
missing_groups = sorted({group for group in groups if group not in FEATURE_GROUPS})
if missing_groups:
raise RuntimeError(f"Profile mapping references unknown groups: {', '.join(missing_groups)}")
return ExpandedFeatureProfile(profiles=profiles, groups=groups)

View File

@@ -0,0 +1,63 @@
"""Feature registry for profile-based capability toggles.
This module stores normalized feature-group definitions used by the
feature profile runtime.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Tuple
@dataclass(frozen=True)
class FeatureGroup:
"""Single feature group and the capabilities it enables."""
routers: Tuple[str, ...] = ()
startup_hooks: Tuple[str, ...] = ()
optional_services: Tuple[str, ...] = ()
features: Tuple[str, ...] = field(default_factory=tuple)
FEATURE_GROUPS: Dict[str, FeatureGroup] = {
"core": FeatureGroup(
features=("core", "health", "onboarding", "research"),
routers=(
"api.component_logic:router",
"api.subscription:router",
"api.onboarding_utils.step3_routes:router",
"api.research.router:router",
),
startup_hooks=(
"services.database:init_database",
),
optional_services=(
"services.scheduler:get_scheduler",
),
),
"podcast": FeatureGroup(
features=("podcast",),
routers=("api.podcast.router:router",),
),
"youtube": FeatureGroup(
features=("youtube",),
routers=("api.youtube.router:router",),
),
"content_planning": FeatureGroup(
features=("content_planning", "strategy_copilot"),
routers=(
"api.content_planning.api.router:router",
"api.content_planning.strategy_copilot:router",
),
),
}
PROFILE_GROUP_MAP: Dict[str, Tuple[str, ...]] = {
"all": tuple(FEATURE_GROUPS.keys()),
"core": ("core",),
"podcast": ("core", "podcast"),
"youtube": ("core", "youtube"),
"planning": ("core", "content_planning"),
}

View File

@@ -0,0 +1,71 @@
"""Runtime helpers for profile-driven feature toggles."""
from __future__ import annotations
from functools import lru_cache
from typing import Tuple
from .feature_profiles import expand_profiles, parse_feature_profiles
from .feature_registry import FEATURE_GROUPS
@lru_cache(maxsize=1)
def _runtime_state() -> dict[str, Tuple[str, ...]]:
profiles = parse_feature_profiles()
expanded = expand_profiles(profiles)
routers = []
startup_hooks = []
optional_services = []
enabled_features = set(expanded.groups)
for group in expanded.groups:
feature_group = FEATURE_GROUPS[group]
routers.extend(feature_group.routers)
startup_hooks.extend(feature_group.startup_hooks)
optional_services.extend(feature_group.optional_services)
enabled_features.update(feature_group.features)
return {
"profiles": expanded.profiles,
"groups": expanded.groups,
"routers": tuple(dict.fromkeys(routers)),
"startup_hooks": tuple(dict.fromkeys(startup_hooks)),
"optional_services": tuple(dict.fromkeys(optional_services)),
"features": tuple(sorted(enabled_features)),
}
def get_active_profiles() -> Tuple[str, ...]:
"""Return validated active profile names."""
return _runtime_state()["profiles"]
def get_enabled_groups() -> Tuple[str, ...]:
"""Return resolved feature-group names."""
return _runtime_state()["groups"]
def get_enabled_routers() -> Tuple[str, ...]:
"""Return enabled router import targets in `module:attribute` format."""
return _runtime_state()["routers"]
def get_enabled_startup_hooks() -> Tuple[str, ...]:
"""Return enabled startup hook import targets in `module:attribute` format."""
return _runtime_state()["startup_hooks"]
def get_enabled_optional_services() -> Tuple[str, ...]:
"""Return enabled optional service import targets in `module:attribute` format."""
return _runtime_state()["optional_services"]
def is_enabled(feature: str) -> bool:
"""Return True when a feature/group name is enabled by active profiles."""
return feature.strip().lower() in _runtime_state()["features"]
def reset_feature_runtime_cache() -> None:
"""Clear runtime cache (useful for tests)."""
_runtime_state.cache_clear()