Coverage for frappe_manager / site_manager / modules / bench_devtools.py: 30%

138 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-07-02 18:13 +0530

1"""BenchDevTools - Development Tools Module 

2 

3This module handles development-related operations for a bench including: 

4- VS Code container attachment 

5- Dev package installation/removal 

6- Debugger configuration 

7- VS Code configuration syncing 

8""" 

9 

10import copy 

11import json 

12import shlex 

13import shutil 

14import subprocess 

15from datetime import datetime 

16from pathlib import Path 

17from typing import TYPE_CHECKING, Any 

18 

19from frappe_manager.docker.docker_exceptions import DockerException 

20from frappe_manager.output_manager import OutputHandler 

21from frappe_manager.output_manager.rich_output import RichOutputHandler 

22from frappe_manager.site_manager import ( 

23 VSCODE_LAUNCH_JSON, 

24 VSCODE_SETTINGS_JSON, 

25 VSCODE_TASKS_JSON, 

26) 

27from frappe_manager.site_manager.exceptions import ( 

28 BenchAttachTocontainerFailed, 

29 BenchFailedToRemoveDevPackages, 

30 BenchNotRunning, 

31) 

32from frappe_manager.utils.helpers import capture_and_format_exception 

33 

34if TYPE_CHECKING: 

35 from frappe_manager.docker.compose_file import ComposeFile 

36 from frappe_manager.docker.docker_client import DockerClient 

37 

38 

39class BenchDevTools: 

40 """Manages development tools and VS Code integration for a bench.""" 

41 

42 def __init__( 

43 self, 

44 docker_client: "DockerClient", 

45 compose_file_manager: "ComposeFile", 

46 bench_path: Path, 

47 bench_name: str, 

48 is_running_fn, 

49 output_handler: OutputHandler | None = None, 

50 ): 

51 """ 

52 Initialize BenchDevTools module. 

53 

54 Args: 

55 docker_client: Docker client instance 

56 compose_file_manager: Compose file manager 

57 bench_path: Path to bench directory 

58 bench_name: Name of the bench 

59 is_running_fn: Function to check if bench is running 

60 output_handler: Handler for output operations 

61 """ 

62 self.docker_client = docker_client 

63 self.compose_file_manager = compose_file_manager 

64 self.bench_path = bench_path 

65 self.bench_name = bench_name 

66 self._is_running = is_running_fn 

67 self.output = output_handler or RichOutputHandler() 

68 self.logger: Any | None = None # Set externally if needed 

69 

70 def get_apps_dev_requirements(self) -> list[str]: 

71 """ 

72 Parse dev requirements from all apps' pyproject.toml files. 

73 

74 Returns: 

75 List of package specs with versions 

76 """ 

77 apps_path = self.bench_path / "workspace" / "frappe-bench" / "apps" 

78 apps_path = apps_path.absolute() 

79 

80 pattern = "**/pyproject.toml" 

81 pyproject_files = list(apps_path.glob(pattern)) 

82 

83 import tomlkit 

84 

85 packages_list = [] 

86 for pyproject_path in pyproject_files: 

87 pyproject = tomlkit.parse(pyproject_path.read_text()) 

88 packages = pyproject.get("tool", {}).get("bench", {}).get("dev-dependencies", {}) 

89 for name, version in packages.items(): 

90 full_name = name + version 

91 packages_list.append(full_name) 

92 

93 return packages_list 

94 

95 def remove_dev_packages(self): 

96 """Remove dev packages from the bench environment.""" 

97 self.output.change_head("Removing dev packages from env") 

98 dev_packages = self.get_apps_dev_requirements() 

99 remove_command = "/workspace/frappe-bench/env/bin/python -m pip uninstall --yes " + " ".join(dev_packages) 

100 try: 

101 self.docker_client.compose.exec("frappe", command=remove_command, user="frappe", stream=False) 

102 except DockerException as e: 

103 raise BenchFailedToRemoveDevPackages(self.bench_name) 

104 self.output.print("Removed dev packages from env") 

105 

106 def install_dev_packages(self): 

107 """Install dev packages in the bench environment.""" 

108 self.output.change_head("Installing dev packages in env") 

109 dev_packages = self.get_apps_dev_requirements() 

110 install_command = "/workspace/frappe-bench/env/bin/python -m pip install --quiet --upgrade " + " ".join( 

111 dev_packages, 

112 ) 

113 try: 

114 self.docker_client.compose.exec("frappe", command=install_command, user="frappe", stream=False) 

115 except DockerException as e: 

116 raise BenchFailedToRemoveDevPackages(self.bench_name) 

117 self.output.print("Installed dev packages in env") 

118 

119 def attach_to_bench(self, user: str, extensions: list[str], workdir: str, debugger: bool = False) -> None: 

120 """ 

121 Attach to running bench container using VS Code Remote Containers. 

122 

123 Args: 

124 user: Username to use in the container 

125 extensions: List of VS Code extensions to install 

126 workdir: Working directory path inside container 

127 debugger: Whether to setup debugging configuration 

128 

129 Raises: 

130 BenchNotRunning: If bench container is not running 

131 BenchAttachTocontainerFailed: If attaching fails 

132 """ 

133 self._verify_bench_running() 

134 

135 if debugger: 

136 self._setup_debugger_config(workdir) 

137 

138 self._verify_vscode_installed() 

139 

140 container_name = self._get_frappe_container_name() 

141 vscode_cmd = self._build_vscode_command(container_name, workdir) 

142 

143 self._update_container_config(user, sorted(extensions)) 

144 self._attach_to_container(vscode_cmd) 

145 

146 def _verify_bench_running(self) -> None: 

147 """Verify bench container is running.""" 

148 if not self._is_running(): 

149 raise BenchNotRunning(self.bench_name) 

150 

151 def _verify_vscode_installed(self) -> None: 

152 """Verify VS Code is installed and accessible.""" 

153 vscode_path = shutil.which("code") 

154 if not vscode_path: 

155 self.output.stop() 

156 self.output.display_error("Visual Studio Code binary i.e 'code' is not accessible via cli") 

157 

158 def _get_frappe_container_name(self) -> str: 

159 """Get the frappe container name and encode it.""" 

160 container_name = self.compose_file_manager.get_container_names() 

161 return container_name["frappe"].encode().hex() 

162 

163 def _build_vscode_command(self, container_hex: str, workdir: str) -> str: 

164 """Build the VS Code remote container command.""" 

165 vscode_path = shutil.which("code") 

166 assert vscode_path is not None, "VS Code binary not found" 

167 return shlex.join([vscode_path, f"--folder-uri=vscode-remote://attached-container+{container_hex}+{workdir}"]) 

168 

169 def _update_container_config(self, user: str, extensions: list[str]) -> None: 

170 """Update container configuration with user and extensions.""" 

171 base_config = [ 

172 { 

173 "remoteUser": user, 

174 "remoteEnv": {"SHELL": "/bin/bash"}, 

175 "customizations": { 

176 "vscode": { 

177 "settings": VSCODE_SETTINGS_JSON, 

178 }, 

179 }, 

180 }, 

181 ] 

182 

183 config_with_extensions = copy.deepcopy(base_config) 

184 config_with_extensions[0]["customizations"]["vscode"]["extensions"] = extensions 

185 

186 labels = {"devcontainer.metadata": json.dumps(config_with_extensions)} 

187 

188 previous_config = self._get_previous_container_config() 

189 

190 if self._config_needs_update(previous_config, extensions, user): 

191 self._apply_new_config(labels) 

192 

193 def _get_previous_container_config(self) -> list[str]: 

194 """Get previous container extension configuration.""" 

195 try: 

196 labels = self.compose_file_manager.get_labels("frappe")[0] 

197 config = json.loads(labels["devcontainer.metadata"]) 

198 return config["customizations"]["vscode"]["extensions"] 

199 except KeyError: 

200 return [] 

201 

202 def _config_needs_update(self, previous_extensions: list[str], new_extensions: list[str], user: str) -> bool: 

203 """Check if container config needs updating.""" 

204 return previous_extensions != new_extensions 

205 

206 def _apply_new_config(self, labels: dict) -> None: 

207 """Apply new container configuration.""" 

208 self.output.change_head("Configuration changed, regenerating label in bench compose") 

209 self.compose_file_manager.configure_service("frappe", labels=labels) 

210 self.output.print("Regenerated bench compose") 

211 self.docker_client.compose.up( 

212 services=["frappe"], 

213 detach=True, 

214 pull="never", 

215 force_recreate=False, 

216 ) 

217 

218 def _setup_debugger_config(self, workdir: str) -> None: 

219 """Setup debugger configuration if workdir is in workspace.""" 

220 workdir = workdir.strip("/") 

221 if not workdir.startswith("workspace"): 

222 self.output.warning("Debugger configuration is only supported for workspace directory") 

223 return 

224 

225 self._sync_vscode_config_files(workdir) 

226 self._install_ruff() 

227 self.output.print("Synced vscode debugger configuration") 

228 

229 def _sync_vscode_config_files(self, workdir: str) -> None: 

230 """Sync VS Code configuration files.""" 

231 workdir = workdir.strip("/") 

232 vscode_dir = self.bench_path / workdir / ".vscode" 

233 vscode_dir.mkdir(exist_ok=True, parents=True) 

234 

235 config_files = {"tasks": VSCODE_TASKS_JSON, "launch": VSCODE_LAUNCH_JSON, "settings": VSCODE_SETTINGS_JSON} 

236 

237 for filename, content in config_files.items(): 

238 file_path = vscode_dir / f"{filename}.json" 

239 if file_path.exists(): 

240 self._backup_config_file(file_path) 

241 self._write_config_file(file_path, content) 

242 

243 def _backup_config_file(self, file_path: Path) -> None: 

244 """Backup existing config file.""" 

245 backup_path = file_path.parent / f"{file_path.stem}.{datetime.now().strftime('%d-%b-%y--%H-%M-%S')}.json" 

246 shutil.copy2(file_path, backup_path) 

247 self.output.print(f"Backup previous '{file_path.name}' : {backup_path}") 

248 

249 def _write_config_file(self, file_path: Path, content: dict) -> None: 

250 """Write new config file.""" 

251 with open(file_path, "w+") as f: 

252 f.write(json.dumps(content, indent=4, sort_keys=True)) 

253 

254 def _install_ruff(self) -> None: 

255 """Install ruff in the container environment.""" 

256 try: 

257 self.docker_client.compose.exec( 

258 service="frappe", 

259 command="/workspace/frappe-bench/env/bin/pip install ruff", 

260 user="frappe", 

261 stream=True, 

262 ) 

263 except DockerException as e: 

264 if self.logger: 

265 self.logger.error(f"ruff installation exception: {capture_and_format_exception()}") 

266 self.output.warning("Not able to install ruff in env") 

267 

268 def _attach_to_container(self, vscode_cmd: str) -> None: 

269 """Attach to the container using VS Code.""" 

270 self.output.change_head("Attaching to Container") 

271 output = subprocess.run(vscode_cmd, shell=True) 

272 

273 if output.returncode != 0: 

274 raise BenchAttachTocontainerFailed(self.bench_name, "frappe")