Files
orion/app/modules/config.py
Samir Boulahtit f89c0382f0
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 32s
CI / pytest (push) Failing after 1h13m39s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(loyalty): wallet debug page, Google Wallet fixes, and module config env_file standardization
- Add wallet diagnostics page at /admin/loyalty/wallet-debug (super admin only)
  with explorer-sidebar pattern: config validation, class status, card inspector,
  save URL tester, recent enrollments, and Apple Wallet status panels
- Fix Google Wallet fat JWT: include both loyaltyClasses and loyaltyObjects in
  payload, use UNDER_REVIEW instead of DRAFT for class reviewStatus
- Fix StorefrontProgramResponse schema: accept google_class_id values while
  keeping exclude=True (was rejecting non-None values)
- Standardize all module configs to read from .env file directly
  (env_file=".env", extra="ignore") matching core Settings pattern
- Add MOD-026 architecture rule enforcing env_file in module configs
- Add SVC-005 noqa support in architecture validator
- Add test files for dev_tools domain_health and isolation_audit services
- Add google_wallet_status.py script for querying Google Wallet API
- Use table_wrapper macro in wallet-debug.html (FE-005 compliance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 22:18:39 +01:00

273 lines
8.0 KiB
Python

# app/modules/config.py
"""
Module configuration auto-discovery utility.
Provides utilities for discovering and loading module-specific configurations.
Each self-contained module can have its own config.py file that defines:
- A Pydantic Settings class for environment-based configuration
- Default values for module settings
- Validation rules for configuration
Module Config Structure:
app/modules/<code>/
└── config.py # ModuleConfig class
Config File Pattern:
# app/modules/mymodule/config.py
from pydantic import Field
from pydantic_settings import BaseSettings
class MyModuleConfig(BaseSettings):
'''Configuration for mymodule.'''
# Module-specific settings (auto-prefixed with module code)
api_timeout: int = Field(default=30, description="API timeout in seconds")
max_retries: int = Field(default=3, description="Max retry attempts")
model_config = {"env_prefix": "MYMODULE_", "env_file": ".env", "extra": "ignore"}
# Export the config class and instance
config_class = MyModuleConfig
config = MyModuleConfig()
Usage:
# Get all module configurations
from app.modules.config import get_all_module_configs, get_module_config
configs = get_all_module_configs()
# Returns: {"billing": <BillingConfig>, "marketplace": <MarketplaceConfig>, ...}
billing_config = get_module_config("billing")
# Returns: <BillingConfig instance>
Integration with ModuleDefinition:
The config auto-discovery works alongside ModuleDefinition.config_schema.
- If config.py exists, it's auto-discovered and loaded
- If config_schema is defined in definition.py, it takes precedence
- Both can be used together for runtime vs static configuration
"""
import importlib
import logging
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pydantic_settings import BaseSettings
logger = logging.getLogger(__name__)
# Path to the modules directory
MODULES_DIR = Path(__file__).parent
def discover_module_config(module_code: str) -> "BaseSettings | None":
"""
Discover and load configuration for a specific module.
Looks for a config.py file in the module directory with either:
- A 'config' variable (instantiated config)
- A 'config_class' variable (config class to instantiate)
Args:
module_code: Module code (e.g., "billing", "marketplace")
Returns:
Instantiated config object, or None if no config found
"""
# Convert module code to directory name (e.g., dev-tools -> dev_tools)
dir_name = module_code.replace("-", "_")
config_file = MODULES_DIR / dir_name / "config.py"
if not config_file.exists():
return None
try:
import_path = f"app.modules.{dir_name}.config"
config_module = importlib.import_module(import_path)
# First, try to get an instantiated config
if hasattr(config_module, "config"):
config = config_module.config
logger.debug(f"Loaded config instance for module {module_code}")
return config
# Otherwise, try to instantiate from config_class
if hasattr(config_module, "config_class"):
config_class = config_module.config_class
config = config_class()
logger.debug(f"Instantiated config class for module {module_code}")
return config
logger.warning(
f"Module {module_code} has config.py but no 'config' or 'config_class' export"
)
return None
except ImportError as e:
logger.error(f"Failed to import config for module {module_code}: {e}")
return None
except Exception as e:
logger.error(f"Failed to load config for module {module_code}: {e}")
return None
@lru_cache(maxsize=1)
def discover_all_module_configs() -> dict[str, "BaseSettings"]:
"""
Discover and load configurations for all modules.
Returns:
Dict mapping module code to config instance
"""
configs: dict[str, BaseSettings] = {}
for module_dir in sorted(MODULES_DIR.iterdir()):
if not module_dir.is_dir():
continue
# Skip special directories
if module_dir.name.startswith("_"):
continue
config_file = module_dir / "config.py"
if not config_file.exists():
continue
# Convert directory name to module code (e.g., dev_tools -> dev-tools)
module_code = module_dir.name.replace("_", "-")
config = discover_module_config(module_code)
if config is not None:
configs[module_code] = config
logger.info(f"Discovered configs for {len(configs)} modules")
return configs
def get_all_module_configs() -> dict[str, "BaseSettings"]:
"""
Get all module configurations.
Returns:
Dict mapping module code to config instance
"""
return discover_all_module_configs()
def get_module_config(module_code: str) -> "BaseSettings | None":
"""
Get configuration for a specific module.
Args:
module_code: Module code (e.g., "billing")
Returns:
Config instance, or None if no config exists
"""
configs = get_all_module_configs()
return configs.get(module_code)
def reload_module_configs() -> dict[str, "BaseSettings"]:
"""
Reload all module configurations (clears cache).
Useful for testing or when configuration files change.
Returns:
Fresh dict of module configurations
"""
discover_all_module_configs.cache_clear()
return discover_all_module_configs()
def get_merged_config(module_code: str) -> dict[str, Any]:
"""
Get merged configuration for a module.
Merges configuration from multiple sources:
1. config.py environment settings (lowest priority)
2. ModuleDefinition.default_config
3. ModuleDefinition.config_schema validation
Args:
module_code: Module code
Returns:
Merged configuration dict
"""
# Import here to avoid circular imports
from app.modules.registry import get_module
config_dict: dict[str, Any] = {}
# 1. Load from config.py if exists
config_instance = get_module_config(module_code)
if config_instance is not None:
config_dict.update(config_instance.model_dump())
# 2. Merge with ModuleDefinition defaults
module = get_module(module_code)
if module:
# default_config is merged, overriding config.py values
if module.default_config:
for key, value in module.default_config.items():
if key not in config_dict:
config_dict[key] = value
# 3. Validate against config_schema if defined
if module.config_schema:
try:
validated = module.config_schema(**config_dict)
config_dict = validated.model_dump()
except Exception as e:
logger.warning(
f"Config validation failed for module {module_code}: {e}"
)
return config_dict
def validate_module_configs() -> list[str]:
"""
Validate all module configurations.
Returns:
List of validation errors (empty if all valid)
"""
errors: list[str] = []
for module_dir in MODULES_DIR.iterdir():
if not module_dir.is_dir() or module_dir.name.startswith("_"):
continue
config_file = module_dir / "config.py"
if not config_file.exists():
continue
module_code = module_dir.name.replace("_", "-")
try:
config = discover_module_config(module_code)
if config is None:
errors.append(
f"Module {module_code}: config.py exists but no config exported"
)
except Exception as e:
errors.append(f"Module {module_code}: config.py failed to load: {e}")
return errors
__all__ = [
"discover_module_config",
"discover_all_module_configs",
"get_all_module_configs",
"get_module_config",
"reload_module_configs",
"get_merged_config",
"validate_module_configs",
]