Coverage for frappe_manager / commands / shell.py: 24%
108 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 base64
2import os
3import sys
4from typing import Annotated
6import typer
7from typer_examples import example
9from frappe_manager.commands import check_bench_migration_required
10from frappe_manager.output_manager import get_global_output_handler
11from frappe_manager.site_manager import NON_BASH_SUPPORTED_SERVICES
12from frappe_manager.site_manager.site import Bench
13from frappe_manager.utils.callbacks import sitename_callback, sites_autocompletion_callback
16def _get_default_user(service: str, user: str | None) -> str | None:
17 if service == "frappe" and not user:
18 return "frappe"
19 return user
22def _get_default_shell_path(service: str, shell_path: str | None) -> str:
23 if shell_path:
24 return shell_path
25 return "/bin/bash" if service not in NON_BASH_SUPPORTED_SERVICES else "sh"
28def _handle_bench_console(
29 bench: Bench,
30 benchname: str,
31 command: str | None,
32 site: str | None,
33 user: str | None,
34 run: bool,
35 output,
36) -> None:
37 if not site:
38 site = benchname
40 python_code = None
42 if command:
43 python_code = command
44 elif not sys.stdin.isatty():
45 python_code = sys.stdin.read()
46 else:
47 if run:
48 exec_cmd = bench.docker_client.compose.docker_compose_cmd + [
49 "run",
50 "--rm",
51 "--entrypoint",
52 "/exec-entrypoint.sh",
53 ]
54 # Use lightweight exec-entrypoint.sh that only handles UID/GID mismatch
55 exec_cmd += ["frappe", "/bin/bash", "-c", f"cd /workspace/frappe-bench && bench --site {site} console"]
56 else:
57 exec_cmd = bench.docker_client.compose.docker_compose_cmd + ["exec"]
58 if user:
59 exec_cmd += ["--user", user]
60 exec_cmd += ["--workdir", "/workspace/frappe-bench"]
61 exec_cmd += ["frappe", "bench", "--site", site, "console"]
63 os.execvp(exec_cmd[0], exec_cmd)
65 frappe_init_wrapper = f"""import sys
66import os
67os.chdir('/workspace/frappe-bench/sites')
68sys.path.insert(0, '/workspace/frappe-bench/apps')
69import frappe
70frappe.init(site='{site}')
71frappe.connect()
73{python_code}
74"""
76 encoded_code = base64.b64encode(frappe_init_wrapper.encode()).decode()
77 bench_console_cmd = (
78 f"FM_EXEC_CODE='{encoded_code}' && echo $FM_EXEC_CODE | base64 -d | /workspace/frappe-bench/env/bin/python"
79 )
81 exit_code = bench.execute_command("frappe", bench_console_cmd, user, use_run=run)
82 if exit_code != 0:
83 raise typer.Exit(exit_code)
86@example(
87 "Open interactive shell as frappe user",
88 "{benchname}",
89 detail="Opens an interactive shell into the frappe service as the frappe user. Useful for ad-hoc debugging.",
90 benchname="mybench",
91)
92@example(
93 "Open shell as root user",
94 "{benchname} --user root",
95 detail="Starts a shell session in the container as the root user. Use with caution for administrative tasks.",
96 benchname="mybench",
97)
98@example(
99 "Execute single command",
100 "{benchname} -c \"bench --version\"",
101 detail="Runs a single command inside the service and exits with the command's exit code.",
102 benchname="mybench",
103)
104@example(
105 "Execute commands from heredoc",
106 "{benchname} <<'EOF'\nls -la\nbench --version\nEOF",
107 detail="Sends a heredoc to the container and executes the provided commands non-interactively.",
108 benchname="mybench",
109)
110@example(
111 "Open shell in nginx container",
112 "{benchname} --service nginx --user nginx",
113 detail="Opens a shell session inside the nginx container for debugging webserver configuration.",
114 benchname="mybench",
115)
116@example(
117 "Run command with passthrough syntax",
118 "{benchname} -- bench migrate",
119 detail="Passes through arguments after '--' directly to the container's shell or compose command.",
120 benchname="mybench",
121)
122@example(
123 "Open interactive bench console with IPython",
124 "{benchname} --bench-console",
125 detail="Opens an IPython console with Frappe initialized so you can interact with frappe APIs.",
126 benchname="mybench",
127)
128@example(
129 "Open bench console for specific site",
130 "{benchname} --bench-console --site mysite.localhost",
131 detail="Opens the bench console for a specific site; useful when the bench hosts multiple sites.",
132 benchname="mybench",
133)
134@example(
135 "Execute Python code in Frappe context",
136 "{benchname} --bench-console -c \"import frappe; print(frappe.__version__)\"",
137 detail="Executes a single Python statement inside the Frappe context and prints the result.",
138 benchname="mybench",
139)
140@example(
141 "Execute Python script from heredoc in Frappe context",
142 "{benchname} --bench-console <<'EOF'\nimport frappe\nprint(frappe.__version__)\nEOF",
143 detail="Executes a multi-line Python script inside the Frappe context provided via heredoc.",
144 benchname="mybench",
145)
146@example(
147 "Execute Python script file in Frappe context",
148 "{benchname} --bench-console < script.py",
149 detail="Reads a Python script from a file and executes it inside the bench's Frappe context.",
150 benchname="mybench",
151)
152def shell(
153 ctx: typer.Context,
154 benchname: Annotated[
155 str | None,
156 typer.Argument(
157 help="Name of the bench.",
158 autocompletion=sites_autocompletion_callback,
159 callback=sitename_callback,
160 ),
161 ] = None,
162 command: Annotated[str | None, typer.Option("-c", "--command", help="Execute command and exit")] = None,
163 user: Annotated[str | None, typer.Option(help="User to connect as", show_default=False)] = None,
164 service: Annotated[str, typer.Option(help="Service to connect to")] = "frappe",
165 shell_path: Annotated[str | None, typer.Option(help="Shell path (e.g., /bin/bash, /bin/sh)")] = None,
166 run: Annotated[bool, typer.Option(help="Use 'docker compose run --rm'")] = False,
167 bench_console: Annotated[
168 bool,
169 typer.Option(
170 "--bench-console",
171 help="Open bench console with Frappe context (interactive IPython or execute code via -c/stdin)",
172 ),
173 ] = False,
174 site: Annotated[
175 str | None, typer.Option(help="Site name for bench console (defaults to benchname if not specified)")
176 ] = None,
177):
178 """
179 Spawn shell for the bench or execute a command.
181 Supports interactive shells, single command execution (-c), heredoc or piped input, and bench-console mode
182 which runs Python code inside an initialized Frappe context.
183 """
185 check_bench_migration_required(benchname)
187 assert benchname is not None
189 services_manager = ctx.obj["services"]
190 output = get_global_output_handler()
191 logger = ctx.obj.get("logger")
192 bench = Bench.get_object(benchname, services_manager, logger=logger, output_handler=output)
194 available_services = bench.get_available_services()
195 if service not in available_services:
196 output.display_error(f"Service '{service}' not found")
197 output.print(f"Available services: {', '.join(sorted(available_services))}")
198 raise typer.Exit(1)
200 if bench_console:
201 if service != "frappe":
202 output.display_error("--bench-console only works with the frappe service")
203 raise typer.Exit(1)
205 user = _get_default_user(service, user)
206 output.stop()
207 _handle_bench_console(bench, benchname, command, site, user, run, output)
208 return
210 output.stop()
212 user = _get_default_user(service, user)
213 shell_path = _get_default_shell_path(service, shell_path)
215 passthrough_args = ctx.args if ctx.args else None
216 is_interactive = output.is_interactive()
217 has_stdin_data = not sys.stdin.isatty()
219 if has_stdin_data and not command and not passthrough_args:
220 stdin_commands = sys.stdin.read()
221 exit_code = bench.execute_command(service, stdin_commands, user, shell_path=shell_path, use_run=run)
222 if exit_code != 0:
223 raise typer.Exit(exit_code)
224 return
226 if passthrough_args:
227 if run:
228 exec_cmd = bench.docker_client.compose.docker_compose_cmd + [
229 "run",
230 "--rm",
231 "--entrypoint",
232 "/exec-entrypoint.sh",
233 ]
234 # Use lightweight exec-entrypoint.sh that only handles UID/GID mismatch
235 exec_cmd += [service, shell_path, "-c", " ".join(passthrough_args)]
236 else:
237 exec_cmd = bench.docker_client.compose.docker_compose_cmd + ["exec"]
238 if user:
239 exec_cmd += ["--user", user]
240 if service == "frappe":
241 exec_cmd += ["--workdir", "/workspace/frappe-bench"]
242 exec_cmd += [service, shell_path, "-c", " ".join(passthrough_args)]
244 if is_interactive:
245 os.execvp(exec_cmd[0], exec_cmd)
246 else:
247 command_str = " ".join(passthrough_args)
248 exit_code = bench.execute_command(service, command_str, user, shell_path=shell_path, use_run=run)
249 if exit_code != 0:
250 raise typer.Exit(exit_code)
251 return
253 if command:
254 exit_code = bench.execute_command(service, command, user, shell_path=shell_path, use_run=run)
255 if exit_code != 0:
256 raise typer.Exit(exit_code)
257 return
259 bench.shell(service, user, shell_path=shell_path, use_run=run)