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
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:13 +0530
1"""BenchDevTools - Development Tools Module
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"""
10import copy
11import json
12import shlex
13import shutil
14import subprocess
15from datetime import datetime
16from pathlib import Path
17from typing import TYPE_CHECKING, Any
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
34if TYPE_CHECKING:
35 from frappe_manager.docker.compose_file import ComposeFile
36 from frappe_manager.docker.docker_client import DockerClient
39class BenchDevTools:
40 """Manages development tools and VS Code integration for a bench."""
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.
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
70 def get_apps_dev_requirements(self) -> list[str]:
71 """
72 Parse dev requirements from all apps' pyproject.toml files.
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()
80 pattern = "**/pyproject.toml"
81 pyproject_files = list(apps_path.glob(pattern))
83 import tomlkit
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)
93 return packages_list
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")
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")
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.
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
129 Raises:
130 BenchNotRunning: If bench container is not running
131 BenchAttachTocontainerFailed: If attaching fails
132 """
133 self._verify_bench_running()
135 if debugger:
136 self._setup_debugger_config(workdir)
138 self._verify_vscode_installed()
140 container_name = self._get_frappe_container_name()
141 vscode_cmd = self._build_vscode_command(container_name, workdir)
143 self._update_container_config(user, sorted(extensions))
144 self._attach_to_container(vscode_cmd)
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)
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")
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()
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}"])
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 ]
183 config_with_extensions = copy.deepcopy(base_config)
184 config_with_extensions[0]["customizations"]["vscode"]["extensions"] = extensions
186 labels = {"devcontainer.metadata": json.dumps(config_with_extensions)}
188 previous_config = self._get_previous_container_config()
190 if self._config_needs_update(previous_config, extensions, user):
191 self._apply_new_config(labels)
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 []
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
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 )
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
225 self._sync_vscode_config_files(workdir)
226 self._install_ruff()
227 self.output.print("Synced vscode debugger configuration")
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)
235 config_files = {"tasks": VSCODE_TASKS_JSON, "launch": VSCODE_LAUNCH_JSON, "settings": VSCODE_SETTINGS_JSON}
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)
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}")
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))
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")
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)
273 if output.returncode != 0:
274 raise BenchAttachTocontainerFailed(self.bench_name, "frappe")