__main__.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. #!/usr/bin/env python3
  2. """
  3. Main entry point for the Boilerplates CLI application.
  4. This file serves as the primary executable when running the CLI.
  5. """
  6. from __future__ import annotations
  7. import importlib
  8. import logging
  9. import pkgutil
  10. import sys
  11. from pathlib import Path
  12. import click
  13. from rich.console import Console
  14. from typer import Option, Typer
  15. from typer.core import TyperGroup
  16. import cli.modules
  17. from cli import __version__
  18. from cli.core import repo
  19. from cli.core.config import ConfigManager
  20. from cli.core.display import DisplayManager
  21. from cli.core.exceptions import ConfigError
  22. from cli.core.registry import registry
  23. class OrderedGroup(TyperGroup):
  24. """Typer Group that lists commands in alphabetical order."""
  25. def list_commands(self, ctx: click.Context) -> list[str]:
  26. return sorted(super().list_commands(ctx))
  27. app = Typer(
  28. help=(
  29. "CLI tool for managing infrastructure boilerplates.\n\n"
  30. "[dim]Easily generate, customize, and deploy templates for Docker Compose, "
  31. "Terraform, Kubernetes, and more.\n\n "
  32. "[white]Made with 💜 by [bold]Christian Lempa[/bold]"
  33. ),
  34. add_completion=True,
  35. rich_markup_mode="rich",
  36. pretty_exceptions_enable=False,
  37. no_args_is_help=True,
  38. cls=OrderedGroup,
  39. )
  40. console = Console()
  41. display = DisplayManager()
  42. def setup_logging(log_level: str = "WARNING") -> None:
  43. """Configure the logging system with the specified log level.
  44. Args:
  45. log_level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
  46. Raises:
  47. ValueError: If the log level is invalid
  48. RuntimeError: If logging configuration fails
  49. """
  50. numeric_level = getattr(logging, log_level.upper(), None)
  51. if not isinstance(numeric_level, int):
  52. raise ValueError(f"Invalid log level '{log_level}'. Valid levels: DEBUG, INFO, WARNING, ERROR, CRITICAL")
  53. try:
  54. logging.basicConfig(
  55. level=numeric_level,
  56. format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
  57. datefmt="%Y-%m-%d %H:%M:%S",
  58. )
  59. logger = logging.getLogger(__name__)
  60. logger.setLevel(numeric_level)
  61. except Exception as e:
  62. raise RuntimeError(f"Failed to configure logging: {e}") from e
  63. @app.callback(invoke_without_command=True)
  64. def main(
  65. _version: bool | None = Option(
  66. None,
  67. "--version",
  68. "-v",
  69. help="Show the application version and exit.",
  70. is_flag=True,
  71. callback=lambda v: console.print(f"boilerplates version {__version__}") or sys.exit(0) if v else None,
  72. is_eager=True,
  73. ),
  74. log_level: str | None = Option(
  75. None,
  76. "--log-level",
  77. help=("Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). If omitted, logging is disabled."),
  78. ),
  79. ) -> None:
  80. """CLI tool for managing infrastructure boilerplates."""
  81. # Disable logging by default; only enable when user provides --log-level
  82. if log_level:
  83. # Re-enable logging and configure
  84. logging.disable(logging.NOTSET)
  85. setup_logging(log_level)
  86. else:
  87. # Silence all logging (including third-party) unless user explicitly requests it
  88. logging.disable(logging.CRITICAL)
  89. # Get context without type annotation (compatible with all Typer versions)
  90. ctx = click.get_current_context()
  91. # Store log level in context for potential use by other commands
  92. ctx.ensure_object(dict)
  93. ctx.obj["log_level"] = log_level
  94. # Trigger config migration early and surface any user-visible notices once.
  95. try:
  96. ConfigManager()
  97. except ConfigError as e:
  98. display.error("Failed to load configuration", details=str(e))
  99. sys.exit(1)
  100. for notice in ConfigManager.consume_migration_notices():
  101. display.warning(notice.message)
  102. # If no subcommand is provided, show help and friendly intro
  103. if ctx.invoked_subcommand is None:
  104. console.print(ctx.get_help())
  105. sys.exit(0)
  106. def _import_modules(modules_path: Path, logger: logging.Logger) -> list[str]:
  107. """Import all modules and return list of failures."""
  108. failed_imports = []
  109. for _finder, name, ispkg in pkgutil.iter_modules([str(modules_path)]):
  110. if not name.startswith("_") and name != "base":
  111. try:
  112. logger.debug(f"Importing module: {name} ({'package' if ispkg else 'file'})")
  113. importlib.import_module(f"cli.modules.{name}")
  114. except ImportError as e:
  115. error_info = f"Import failed for '{name}': {e!s}"
  116. failed_imports.append(error_info)
  117. logger.warning(error_info)
  118. except Exception as e:
  119. error_info = f"Unexpected error importing '{name}': {e!s}"
  120. failed_imports.append(error_info)
  121. logger.error(error_info)
  122. return failed_imports
  123. def _register_repo_command(logger: logging.Logger) -> list[str]:
  124. """Register repo command and return list of failures."""
  125. failed = []
  126. try:
  127. logger.debug("Registering repo command")
  128. repo.register_cli(app)
  129. except Exception as e:
  130. error_info = f"Repo command registration failed: {e!s}"
  131. failed.append(error_info)
  132. logger.warning(error_info)
  133. return failed
  134. def _register_module_classes(logger: logging.Logger) -> tuple[list, list[str]]:
  135. """Register template-based modules and return (module_classes, failures)."""
  136. failed_registrations = []
  137. module_classes = list(registry.iter_module_classes())
  138. logger.debug(f"Registering {len(module_classes)} template-based modules")
  139. for _name, module_cls in module_classes:
  140. try:
  141. logger.debug(f"Registering module class: {module_cls.__name__}")
  142. module_cls.register_cli(app)
  143. except Exception as e:
  144. error_info = f"Registration failed for '{module_cls.__name__}': {e!s}"
  145. failed_registrations.append(error_info)
  146. logger.warning(error_info)
  147. display.warning(error_info)
  148. return module_classes, failed_registrations
  149. def _build_error_details(failed_imports: list[str], failed_registrations: list[str]) -> str:
  150. """Build detailed error message from failures."""
  151. error_details = []
  152. if failed_imports:
  153. error_details.extend(["Import failures:"] + [f" - {err}" for err in failed_imports])
  154. if failed_registrations:
  155. error_details.extend(["Registration failures:"] + [f" - {err}" for err in failed_registrations])
  156. return "\n".join(error_details) if error_details else ""
  157. def init_app() -> None:
  158. """Initialize the application by discovering and registering modules.
  159. Raises:
  160. ImportError: If critical module import operations fail
  161. RuntimeError: If application initialization fails
  162. """
  163. logger = logging.getLogger(__name__)
  164. failed_imports = []
  165. failed_registrations = []
  166. try:
  167. # Auto-discover and import all modules
  168. modules_path = Path(cli.modules.__file__).parent
  169. logger.debug(f"Discovering modules in {modules_path}")
  170. failed_imports = _import_modules(modules_path, logger)
  171. # Register core repo command
  172. repo_failures = _register_repo_command(logger)
  173. # Register template-based modules
  174. module_classes, failed_registrations = _register_module_classes(logger)
  175. failed_registrations.extend(repo_failures)
  176. # Validate we have modules
  177. if not module_classes and not failed_imports:
  178. raise RuntimeError("No modules found to register")
  179. # Log summary
  180. successful_modules = len(module_classes) - len(failed_registrations)
  181. logger.info(f"Application initialized: {successful_modules} modules registered successfully")
  182. if failed_imports:
  183. logger.info(f"Module import failures: {len(failed_imports)}")
  184. if failed_registrations:
  185. logger.info(f"Module registration failures: {len(failed_registrations)}")
  186. except Exception as e:
  187. details = _build_error_details(failed_imports, failed_registrations) or str(e)
  188. raise RuntimeError(f"Application initialization failed: {details}") from e
  189. def run() -> None:
  190. """Run the CLI application."""
  191. # Configure logging early if --log-level is provided
  192. if "--log-level" in sys.argv:
  193. try:
  194. log_level_index = sys.argv.index("--log-level") + 1
  195. if log_level_index < len(sys.argv):
  196. log_level = sys.argv[log_level_index]
  197. logging.disable(logging.NOTSET)
  198. setup_logging(log_level)
  199. except (ValueError, IndexError):
  200. pass # Let Typer handle argument parsing errors
  201. try:
  202. init_app()
  203. app()
  204. except (ValueError, RuntimeError) as e:
  205. # Handle configuration and initialization errors cleanly
  206. display.error(str(e))
  207. sys.exit(1)
  208. except ImportError as e:
  209. # Handle module import errors with detailed info
  210. display.error(f"Module Import Error: {e}")
  211. sys.exit(1)
  212. except KeyboardInterrupt:
  213. # Handle Ctrl+C gracefully
  214. display.warning("Operation cancelled by user")
  215. sys.exit(130)
  216. except Exception as e:
  217. # Handle unexpected errors - show simplified message
  218. display.error(str(e))
  219. display.info("Use --log-level DEBUG for more details")
  220. sys.exit(1)
  221. if __name__ == "__main__":
  222. run()