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

1import base64 

2import os 

3import sys 

4from typing import Annotated 

5 

6import typer 

7from typer_examples import example 

8 

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 

14 

15 

16def _get_default_user(service: str, user: str | None) -> str | None: 

17 if service == "frappe" and not user: 

18 return "frappe" 

19 return user 

20 

21 

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" 

26 

27 

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 

39 

40 python_code = None 

41 

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"] 

62 

63 os.execvp(exec_cmd[0], exec_cmd) 

64 

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() 

72 

73{python_code} 

74""" 

75 

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 ) 

80 

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) 

84 

85 

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. 

180 

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 """ 

184 

185 check_bench_migration_required(benchname) 

186 

187 assert benchname is not None 

188 

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) 

193 

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) 

199 

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) 

204 

205 user = _get_default_user(service, user) 

206 output.stop() 

207 _handle_bench_console(bench, benchname, command, site, user, run, output) 

208 return 

209 

210 output.stop() 

211 

212 user = _get_default_user(service, user) 

213 shell_path = _get_default_shell_path(service, shell_path) 

214 

215 passthrough_args = ctx.args if ctx.args else None 

216 is_interactive = output.is_interactive() 

217 has_stdin_data = not sys.stdin.isatty() 

218 

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 

225 

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)] 

243 

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 

252 

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 

258 

259 bench.shell(service, user, shell_path=shell_path, use_run=run)