diff --git a/backend/alwrity_utils/__init__.py b/backend/alwrity_utils/__init__.py index 9edc5227..1ad146a9 100644 --- a/backend/alwrity_utils/__init__.py +++ b/backend/alwrity_utils/__init__.py @@ -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' ] diff --git a/backend/alwrity_utils/feature_profiles.py b/backend/alwrity_utils/feature_profiles.py new file mode 100644 index 00000000..c13636c8 --- /dev/null +++ b/backend/alwrity_utils/feature_profiles.py @@ -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) diff --git a/backend/alwrity_utils/feature_registry.py b/backend/alwrity_utils/feature_registry.py new file mode 100644 index 00000000..0c47413d --- /dev/null +++ b/backend/alwrity_utils/feature_registry.py @@ -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"), +} diff --git a/backend/alwrity_utils/feature_runtime.py b/backend/alwrity_utils/feature_runtime.py new file mode 100644 index 00000000..be38bc78 --- /dev/null +++ b/backend/alwrity_utils/feature_runtime.py @@ -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()