feat: add module config and migrations auto-discovery infrastructure
Add self-contained configuration and migrations support for modules:
Config auto-discovery (app/modules/config.py):
- Modules can have config.py with Pydantic Settings
- Environment variables prefixed with MODULE_NAME_
- Auto-discovered via get_module_config()
Migrations auto-discovery:
- Each module has migrations/versions/ directory
- Alembic discovers module migrations automatically
- Naming convention: {module}_{seq}_{description}.py
New architecture rules (MOD-013 to MOD-015):
- MOD-013: config.py should export config/config_class
- MOD-014: Migrations must follow naming convention
- MOD-015: Migrations directory must have __init__.py
Created for all 11 self-contained modules:
- config.py placeholder files
- migrations/ directories with __init__.py files
Added core and tenancy module definitions for completeness.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
190
app/modules/discovery.py
Normal file
190
app/modules/discovery.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# app/modules/discovery.py
|
||||
"""
|
||||
Module auto-discovery system.
|
||||
|
||||
Provides automatic discovery of modules from app/modules/*/definition.py.
|
||||
This enables true plug-and-play modules - just create a module directory
|
||||
with a definition.py file and it will be automatically registered.
|
||||
|
||||
Module Discovery Rules:
|
||||
1. Module must have app/modules/<code>/definition.py
|
||||
2. definition.py must define a ModuleDefinition instance
|
||||
3. The instance variable name should be <code>_module (e.g., cms_module)
|
||||
4. Module tier is determined by is_core and is_internal flags
|
||||
|
||||
Usage:
|
||||
# Auto-discover all modules (replaces manual imports in registry.py)
|
||||
from app.modules.discovery import discover_modules
|
||||
|
||||
MODULES = discover_modules()
|
||||
|
||||
Directory Structure for Auto-Discovery:
|
||||
app/modules/
|
||||
├── cms/
|
||||
│ ├── __init__.py
|
||||
│ ├── definition.py <- Must export ModuleDefinition
|
||||
│ ├── routes/
|
||||
│ │ ├── api/
|
||||
│ │ └── pages/
|
||||
│ ├── services/
|
||||
│ ├── models/
|
||||
│ ├── schemas/
|
||||
│ ├── templates/
|
||||
│ ├── static/
|
||||
│ ├── locales/
|
||||
│ └── tasks/
|
||||
└── analytics/
|
||||
└── ...
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Modules directory
|
||||
MODULES_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_modules() -> dict[str, "ModuleDefinition"]:
|
||||
"""
|
||||
Auto-discover all modules from app/modules/*/definition.py.
|
||||
|
||||
Scans the modules directory for subdirectories containing definition.py,
|
||||
imports them, and extracts ModuleDefinition instances.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping module code to ModuleDefinition
|
||||
|
||||
Example:
|
||||
>>> modules = discover_modules()
|
||||
>>> modules.keys()
|
||||
dict_keys(['cms', 'analytics', 'billing', ...])
|
||||
"""
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
modules: dict[str, ModuleDefinition] = {}
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
logger.warning(f"Modules directory not found: {MODULES_DIR}")
|
||||
return modules
|
||||
|
||||
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||
# Skip non-directories and special files
|
||||
if not module_dir.is_dir():
|
||||
continue
|
||||
if module_dir.name.startswith("_") or module_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
definition_file = module_dir / "definition.py"
|
||||
if not definition_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Import the definition module
|
||||
dir_name = module_dir.name
|
||||
import_path = f"app.modules.{dir_name}.definition"
|
||||
definition_module = importlib.import_module(import_path)
|
||||
|
||||
# Find ModuleDefinition instances in the module
|
||||
for attr_name in dir(definition_module):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
attr = getattr(definition_module, attr_name)
|
||||
if isinstance(attr, ModuleDefinition):
|
||||
modules[attr.code] = attr
|
||||
logger.debug(f"Discovered module: {attr.code} from {import_path}")
|
||||
break
|
||||
else:
|
||||
logger.warning(f"No ModuleDefinition found in {import_path}")
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import {import_path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering module in {module_dir}: {e}")
|
||||
|
||||
logger.info(f"Auto-discovered {len(modules)} modules")
|
||||
return modules
|
||||
|
||||
|
||||
def discover_modules_by_tier() -> dict[str, dict[str, "ModuleDefinition"]]:
|
||||
"""
|
||||
Discover modules and organize by tier.
|
||||
|
||||
Returns:
|
||||
Dict with keys 'core', 'optional', 'internal' mapping to module dicts
|
||||
|
||||
Example:
|
||||
>>> by_tier = discover_modules_by_tier()
|
||||
>>> by_tier['core'].keys()
|
||||
dict_keys(['core', 'tenancy', 'cms', 'customers'])
|
||||
"""
|
||||
modules = discover_modules()
|
||||
|
||||
result = {
|
||||
"core": {},
|
||||
"optional": {},
|
||||
"internal": {},
|
||||
}
|
||||
|
||||
for code, module in modules.items():
|
||||
tier = module.get_tier()
|
||||
result[tier][code] = module
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_module_codes() -> set[str]:
|
||||
"""Get all discovered module codes."""
|
||||
return set(discover_modules().keys())
|
||||
|
||||
|
||||
def validate_module_discovery() -> list[str]:
|
||||
"""
|
||||
Validate that all module directories have proper structure.
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if all valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return ["Modules directory not found"]
|
||||
|
||||
for module_dir in sorted(MODULES_DIR.iterdir()):
|
||||
if not module_dir.is_dir():
|
||||
continue
|
||||
if module_dir.name.startswith("_") or module_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check for definition.py
|
||||
definition_file = module_dir / "definition.py"
|
||||
if not definition_file.exists():
|
||||
# Only warn if it looks like a module (has __init__.py)
|
||||
if (module_dir / "__init__.py").exists():
|
||||
errors.append(
|
||||
f"Module directory '{module_dir.name}' has __init__.py but no definition.py"
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for __init__.py
|
||||
init_file = module_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
errors.append(f"Module '{module_dir.name}' missing __init__.py")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
__all__ = [
|
||||
"discover_modules",
|
||||
"discover_modules_by_tier",
|
||||
"get_module_codes",
|
||||
"validate_module_discovery",
|
||||
"MODULES_DIR",
|
||||
]
|
||||
Reference in New Issue
Block a user