Coverage for frappe_manager / commands / __init__.py: 55%
267 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:13 +0530
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:13 +0530
1import os
2import secrets
3import shutil
4import sys
5import uuid
6from pathlib import Path
7from typing import Annotated, List, Optional, cast
9import typer
10from typer_examples import install
11from rich.panel import Panel
13from frappe_manager import (
14 CLI_BENCH_CONFIG_FILE_NAME,
15 CLI_BENCHES_DIRECTORY,
16 CLI_DIR,
17 CLI_FM_CONFIG_PATH,
18 DEFAULT_EXTENSIONS,
19 STABLE_APP_BRANCH_MAPPING_LIST,
20 EnableDisableOptionsEnum,
21 SiteServicesEnum,
22)
23from frappe_manager.commands.self import self_app
24from frappe_manager.commands.services import services_app
25from frappe_manager.commands.ssl import ssl_app
26from frappe_manager.docker import ComposeFile, DockerClient
27from frappe_manager.logger import log
28from frappe_manager.logger.context import LoggerContext
29from frappe_manager.metadata_manager import FMConfigManager
30from frappe_manager.migration_manager.bench_migration_state import (
31 bench_needs_migration,
32 get_bench_migration_version,
33 set_bench_migration_version,
34)
35from frappe_manager.migration_manager.migration_executor import (
36 MigrationExecutor,
37 get_benches_needing_migration,
38 needs_fm_infrastructure_migration,
39 needs_migration,
40)
41from frappe_manager.migration_manager.version import Version
42from frappe_manager.ngrok import create_tunnel
43from frappe_manager.output_manager import OutputHandler, get_global_output_handler, spinner, temporary_stop
44from frappe_manager.output_manager.logging_output import LoggingOutputHandler
45from frappe_manager.services_manager.services import ServicesManager
46from frappe_manager.services_manager.services_exceptions import ServicesNotCreated
47from frappe_manager.site_manager.bench_config import AppConfig, BenchConfig, FMBenchEnvType, RestartPolicyEnum
48from frappe_manager.site_manager.bench_service import BenchService
49from frappe_manager.site_manager.domain_conflict import DomainConflictError, validate_domains_unique
50from frappe_manager.site_manager.exceptions import BenchNotRunning
51from frappe_manager.site_manager.modules.app_cloner import AppCloner
52from frappe_manager.site_manager.site import Bench
53from frappe_manager.utils.callbacks import (
54 alias_domains_validation_callback,
55 apps_list_validation_callback,
56 code_command_extensions_callback,
57 create_command_sitename_callback,
58 sitename_callback,
59 sites_autocompletion_callback,
60 version_callback,
61)
62from frappe_manager.utils.helpers import (
63 get_current_fm_version,
64 is_cli_help_called,
65)
66from frappe_manager.utils.site import pull_docker_images, validate_sitename
68# Helper functions
71def get_bench_arg_from_context(ctx: typer.Context) -> str | None:
72 """
73 Extract bench/site name from command context.
74 Commands use different parameter names (benchname, sitename, bench_name).
75 """
76 return ctx.params.get("benchname") or ctx.params.get("sitename") or ctx.params.get("bench_name")
79def check_bench_migration_required(bench_name: str | None) -> None:
80 from frappe_manager.migration_manager.bench_migration_state import bench_needs_migration
82 if not bench_name:
83 return
85 bench_path = CLI_BENCHES_DIRECTORY / bench_name
87 if not bench_path.exists():
88 return
90 current_version = Version(get_current_fm_version())
92 if bench_needs_migration(bench_path, current_version):
93 output = get_global_output_handler()
94 output.stop()
96 bench_path = CLI_BENCHES_DIRECTORY / bench_name
97 from frappe_manager.migration_manager.bench_migration_state import get_bench_migration_version
99 bench_version = get_bench_migration_version(bench_path)
100 fm_version = Version(get_current_fm_version())
102 output.warning(f"Bench migration required: {bench_name} (v{bench_version} → v{fm_version})\n", emoji_code="")
103 output.print("Bench migration updates configuration and applies necessary changes.\n", emoji_code="")
104 output.print(f"Run: [cyan]fm migrate {bench_name}[/cyan]\n", emoji_code="")
105 raise typer.Exit(0)
108# Create main Typer app
109app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich")
110# Activate typer-examples for the main Typer app
111install(app)
113# Register subcommands
114app.add_typer(services_app, name="services", help="Handle global services.")
115app.add_typer(self_app, name="self", help="Perform operations related to the [bold][blue]fm[/bold][/blue] itself.")
116app.add_typer(ssl_app, name="ssl", help="Perform operations related to ssl.")
119# App callback (runs before all commands)
120@app.callback()
121def app_callback(
122 ctx: typer.Context,
123 verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output (info level)")] = False,
124 log_level: Annotated[
125 str | None,
126 typer.Option("--log-level", help="Set log level explicitly (debug|info|warning|error)"),
127 ] = None,
128 non_interactive: Annotated[
129 bool,
130 typer.Option(
131 "--non-interactive",
132 "-n",
133 help="Run without interactive prompts. All prompts will error with suggestions for required flags.",
134 ),
135 ] = False,
136 version: Annotated[
137 bool | None,
138 typer.Option("--version", "-V", help="Show Version.", callback=version_callback),
139 ] = None,
140):
141 """
142 Docker Compose based CLI for managing Frappe benches.
144 Create, manage, and develop isolated Frappe environments using containers.
145 Each bench runs independently with its own apps, database, and configuration.
146 """
147 ctx.obj = {}
149 correlation_id = str(uuid.uuid4())
150 ctx.obj["correlation_id"] = correlation_id
152 # Import early for validation error reporting
153 from frappe_manager.output_manager import get_global_output_handler, set_global_output_handler
155 # Determine effective log level
156 if log_level:
157 # Explicit --log-level takes precedence
158 level_name = log_level.upper()
160 # Validate log level
161 valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]
162 if level_name not in valid_levels:
163 output = get_global_output_handler()
164 output.display_error(f"Invalid log level: {log_level}. Must be one of: {', '.join(valid_levels).lower()}")
165 raise typer.Exit(1)
166 elif verbose:
167 # -v flag sets INFO level
168 level_name = "INFO"
169 else:
170 # Default: WARNING
171 level_name = "WARNING"
173 # Store in context for commands
174 ctx.obj["log_level"] = level_name
175 ctx.obj["verbose"] = verbose or level_name in ["INFO", "DEBUG"]
176 ctx.obj["non_interactive"] = non_interactive
178 # Upgrade global output handler to LoggingOutputHandler now that we have CLI args
179 from frappe_manager.logger import ContextualLogger
181 basic_handler = get_global_output_handler()
182 logger_context = LoggerContext(correlation_id=correlation_id)
183 contextual_logger = ContextualLogger(log.get_logger(file_level="DEBUG"), context=logger_context)
184 upgraded_handler = LoggingOutputHandler(basic_handler, contextual_logger)
185 set_global_output_handler(upgraded_handler)
187 ctx.obj["logger"] = contextual_logger
189 output = get_global_output_handler()
190 output.set_interactive_mode(non_interactive_flag=non_interactive)
192 help_called = is_cli_help_called(ctx)
193 ctx.obj["is_help_called"] = help_called
195 if not help_called:
196 output = get_global_output_handler()
197 with spinner(output, "Working"):
198 if not CLI_DIR.exists():
199 CLI_DIR.mkdir(parents=True, exist_ok=True)
200 CLI_BENCHES_DIRECTORY.mkdir(parents=True, exist_ok=True)
201 output.print(f"fm directory doesn't exists! Created at -> {CLI_DIR!s}")
202 elif not CLI_DIR.is_dir():
203 output.exit("Sites directory is not a directory! Aborting!")
205 global logger
206 console_level = level_name if ctx.obj["verbose"] else None
208 fm_config_manager: FMConfigManager = FMConfigManager.import_from_toml()
209 file_level = fm_config_manager.logs.file_level
211 logger = log.get_logger(console_level=console_level, file_level=file_level)
213 contextual_logger.logger = logger
215 logger.info("")
216 logger.info(f"{':' * 20}FM Invoked{':' * 20}")
217 logger.info("")
219 logger.info(f"RUNNING COMMAND: {' '.join(sys.argv[1:])}")
220 logger.info(f"LOG LEVEL: {level_name}")
221 logger.info("-" * 20)
223 if not DockerClient().server_running():
224 output.exit("Docker daemon not running. Please start docker service")
226 if not CLI_FM_CONFIG_PATH.exists():
227 output.print("First installation detected. Pulling docker images...️", "🔍")
229 completed_status = pull_docker_images()
231 if not completed_status:
232 if CLI_DIR.exists():
233 shutil.rmtree(CLI_DIR)
234 output.exit("Aborting. Not able to pull all required Docker images")
236 current_version = Version(get_current_fm_version())
237 fm_config_manager.version = current_version
238 fm_config_manager.export_to_toml()
240 invoked_command = ctx.invoked_subcommand or "no-command"
242 from frappe_manager.migration_manager.migration_constants import (
243 MIGRATION_CHECK_WHITELIST_BENCH_COMMANDS,
244 MIGRATION_CHECK_WHITELIST_COMMANDS,
245 )
247 def get_full_command_path() -> str:
248 """
249 Build full command path from sys.argv for multi-level commands.
251 Multi-level commands (self, ssl, services) have subcommands and return paths like "ssl add".
252 Single-level commands take arguments (start, stop, create) and return just the base command.
253 Stops parsing at flags (--) or path-like arguments (/ or ~). Limits depth to 2 levels max.
254 """
255 # Commands that have subcommands (multi-level structure)
256 MULTI_LEVEL_COMMANDS = {"self", "ssl", "services"}
258 if len(sys.argv) < 2:
259 return invoked_command
261 first_command = sys.argv[1] if len(sys.argv) > 1 else invoked_command
263 # If it's not a multi-level command, return just the first command
264 if first_command not in MULTI_LEVEL_COMMANDS:
265 return first_command if not first_command.startswith("-") else invoked_command
267 # For multi-level commands, build the full path (max 2 levels)
268 command_parts = []
269 for arg in sys.argv[1:]:
270 # Stop at flags
271 if arg.startswith("-"):
272 break
273 # Stop at paths (likely bench names like /path or ~/path)
274 if arg.startswith("/") or arg.startswith("~"):
275 break
276 # Limit to max 2 command levels (e.g., "self compose")
277 if len(command_parts) >= 2:
278 break
280 command_parts.append(arg)
282 return " ".join(command_parts) if command_parts else invoked_command
284 full_command = get_full_command_path()
286 commands_skip_migration_check = MIGRATION_CHECK_WHITELIST_COMMANDS
288 commands_skip_bench_migration = ["stop", "delete"] + MIGRATION_CHECK_WHITELIST_BENCH_COMMANDS
290 # Get bench argument if present
291 bench_arg = get_bench_arg_from_context(ctx)
292 bench_path = CLI_BENCHES_DIRECTORY / bench_arg if bench_arg else None
294 # Check migration states
295 fm_infrastructure_version = fm_config_manager.get_system_migration_version()
296 current_version = Version(get_current_fm_version())
297 infra_needs_migration = fm_infrastructure_version < current_version
299 bench_needs_migration_flag = False
300 bench_version = None
301 if bench_path and bench_path.exists() and invoked_command not in commands_skip_bench_migration:
302 bench_needs_migration_flag = bench_needs_migration(bench_path, current_version)
303 if bench_needs_migration_flag:
304 bench_version = get_bench_migration_version(bench_path)
306 should_check_migration = (
307 invoked_command not in commands_skip_migration_check
308 and full_command not in commands_skip_migration_check
309 )
311 if should_check_migration:
312 output = get_global_output_handler()
314 # Scenario 1: Infra needs migration
315 if infra_needs_migration:
316 output.warning(
317 f"FM infrastructure needs update: v{fm_infrastructure_version} -> v{current_version}",
318 )
319 output.print("This updates CLI config and global services", emoji_code=" ")
320 output.print("", emoji_code="")
322 with temporary_stop(output):
323 infra_choice = output.prompt_ask(
324 prompt="How would you like to proceed?",
325 choices=[
326 {"name": "Update now (recommended)", "value": "update"},
327 {"name": "Update later (run 'fm migrate' when ready)", "value": "skip"},
328 ],
329 default="update",
330 required_flag="'fm migrate' (run migration explicitly)",
331 )
333 if infra_choice == "update":
334 output.print("\n🔄 Updating FM infrastructure...\n", emoji_code="")
336 migrations = MigrationExecutor(
337 fm_config_manager,
338 migrate_fm_infrastructure=True,
339 auto_proceed=True,
340 on_failure="rollback",
341 output_handler=output,
342 )
344 with temporary_stop(output):
345 migration_status = migrations.execute()
347 if not migration_status:
348 output.display_error("FM infrastructure update failed")
349 output.print("Please run 'fm migrate' manually to fix.", emoji_code="")
350 raise typer.Exit(1)
352 fm_config_manager.set_system_migration_version(current_version)
353 fm_config_manager.export_to_toml()
355 output.print(f"FM infrastructure updated to v{current_version}\n", emoji_code="✅ ")
357 # Now check bench migration if bench arg present
358 if bench_needs_migration_flag and bench_arg and bench_version:
359 output.warning(
360 f"Bench '{bench_arg}' needs migration: v{bench_version} -> v{current_version}",
361 )
362 output.print("This may modify bench configuration and services.", emoji_code="")
363 output.print("", emoji_code="")
365 with temporary_stop(output):
366 bench_choice = output.prompt_ask(
367 prompt=f"Migrate bench '{bench_arg}' now?",
368 choices=[
369 {"name": "Update now", "value": "update"},
370 {
371 "name": f"Update later (run 'fm migrate {bench_arg}' when ready)",
372 "value": "skip",
373 },
374 ],
375 default="update",
376 required_flag=f"'fm migrate {bench_arg}' (run migration explicitly)",
377 )
379 if bench_choice == "update":
380 output.print(f"\nMigrating bench '{bench_arg}'...\n", emoji_code="🔄 ")
382 bench_migrations = MigrationExecutor(
383 fm_config_manager,
384 target_benches=[bench_arg],
385 auto_proceed=True,
386 on_failure="rollback",
387 output_handler=output,
388 )
390 with temporary_stop(output):
391 bench_status = bench_migrations.execute()
393 if not bench_status:
394 output.display_error(f"Bench migration failed for '{bench_arg}'")
395 output.print(f"Please run 'fm migrate {bench_arg}' manually.", emoji_code="")
396 raise typer.Exit(1)
398 set_bench_migration_version(bench_path, current_version) # type: ignore[arg-type]
399 output.print(f"Bench '{bench_arg}' migrated to v{current_version}\n", emoji_code="✅ ")
400 else:
401 output.print("", emoji_code="")
402 output.warning(f"Skipped bench migration. Run 'fm migrate {bench_arg}' when ready.")
403 output.print("Note: Bench may not work correctly until migrated.", emoji_code="")
404 output.print("", emoji_code="")
405 output.display_error(f"Cannot {invoked_command} '{bench_arg}' - migration required")
406 output.print(f"Run 'fm migrate {bench_arg}' first", emoji_code="")
407 raise typer.Exit(1)
409 else:
410 output.display_error("Cannot proceed - FM infrastructure migration required")
411 output.print("Run 'fm migrate' when ready", emoji_code="")
412 raise typer.Exit(1)
414 # Scenario 2: Only bench needs migration (infra already up-to-date)
415 elif bench_needs_migration_flag and bench_arg and bench_version:
416 output.warning(f"Bench '{bench_arg}' needs migration: v{bench_version} -> v{current_version}")
417 output.print("This may modify bench configuration and services.", emoji_code="")
418 output.print("", emoji_code="")
420 with temporary_stop(output):
421 bench_choice = output.prompt_ask(
422 prompt=f"Migrate bench '{bench_arg}' now?",
423 choices=[
424 {"name": "Update now", "value": "update"},
425 {"name": f"Update later (run 'fm migrate {bench_arg}' when ready)", "value": "skip"},
426 ],
427 default="update",
428 required_flag=f"'fm migrate {bench_arg}' (run migration explicitly)",
429 )
431 if bench_choice == "update":
432 output.print(f"\nMigrating bench '{bench_arg}'...\n", emoji_code="🔄 ")
434 bench_migrations = MigrationExecutor(
435 fm_config_manager,
436 target_benches=[bench_arg],
437 auto_proceed=True,
438 on_failure="rollback",
439 output_handler=output,
440 )
442 with temporary_stop(output):
443 bench_status = bench_migrations.execute()
445 if not bench_status:
446 output.display_error(f"Bench migration failed for '{bench_arg}'")
447 output.print(f"Please run 'fm migrate {bench_arg}' manually.", emoji_code="")
448 raise typer.Exit(1)
450 set_bench_migration_version(bench_path, current_version) # type: ignore[arg-type]
451 output.print(f"Bench '{bench_arg}' migrated to v{current_version}\n", emoji_code="✅ ")
452 else:
453 output.print("", emoji_code="")
454 output.warning(f"Skipped bench migration. Run 'fm migrate {bench_arg}' when ready.")
455 output.print("Note: Bench may not work correctly until migrated.", emoji_code="")
456 output.print("", emoji_code="")
457 output.display_error(f"Cannot {invoked_command} '{bench_arg}' - migration required")
458 output.print(f"Run 'fm migrate {bench_arg}' first", emoji_code="")
459 raise typer.Exit(1)
461 services_manager: ServicesManager = ServicesManager(
462 verbose=ctx.obj["verbose"],
463 invoked_subcommand=ctx.invoked_subcommand,
464 )
466 services_manager.init()
468 # Don't start services for migrate command (migration handles its own service lifecycle)
469 should_start_services = invoked_command != "migrate"
471 try:
472 services_manager.entrypoint_checks(start=should_start_services)
473 except ServicesNotCreated as e:
474 services_manager.remove_itself()
475 output.exit(f"Not able to create services. {e}")
477 ctx.obj["services"] = services_manager
478 ctx.obj["fm_config_manager"] = fm_config_manager
481# Import extracted read-only commands (Step 3)
482# Import extracted remaining commands (Step 6)
483from frappe_manager.commands.code import code
485# Import extracted complex commands (Step 5)
486from frappe_manager.commands.create import create
487from frappe_manager.commands.delete import delete
488from frappe_manager.commands.info import info
489from frappe_manager.commands.list import list as list_benches
490from frappe_manager.commands.logs import logs
491from frappe_manager.commands.migrate import migrate
492from frappe_manager.commands.ngrok import ngrok
493from frappe_manager.commands.reset import reset
494from frappe_manager.commands.restart import restart
495from frappe_manager.commands.shell import shell
497# Import extracted lifecycle commands (Step 4)
498from frappe_manager.commands.start import start
499from frappe_manager.commands.stop import stop
500from frappe_manager.commands.update import update
502# Register all commands with the app
503app.command(name="create", no_args_is_help=True)(create)
504app.command(name="delete")(delete)
505app.command(name="list")(list_benches)
506app.command(name="start")(start)
507app.command(name="stop")(stop)
508app.command(name="code")(code)
509app.command(name="logs")(logs)
510app.command(name="shell", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(shell)
511app.command(name="info")(info)
512app.command(name="update", no_args_is_help=True)(update)
513app.command(name="reset")(reset)
514app.command(name="restart")(restart)
515app.command(name="ngrok")(ngrok)
516app.command(name="migrate")(migrate)
518# Export app and helpers for backward compatibility
519__all__ = ["app", "app_callback", "check_bench_migration_required", "get_bench_arg_from_context"]