Coverage for frappe_manager / site_manager / bench_service.py: 44%

126 statements  

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

1""" 

2BenchService - Service layer for bench operations 

3 

4This module provides a clean service layer between the CLI and domain models. 

5It encapsulates bench creation, retrieval, and listing logic to reduce coupling 

6between the CLI commands and internal implementation details. 

7 

8Benefits: 

9- Single responsibility: manages bench lifecycle 

10- Dependency injection: receives services, not globals 

11- Testability: easy to mock in tests 

12- Reusability: can be used by CLI, API, or other interfaces 

13""" 

14 

15from pathlib import Path 

16 

17from rich.table import Table 

18 

19from frappe_manager.docker import ComposeFile, DockerClient 

20from frappe_manager.logger import log 

21from frappe_manager.output_manager import OutputHandler 

22from frappe_manager.output_manager.rich_output import RichOutputHandler 

23from frappe_manager.services_manager.services import ServicesManager 

24from frappe_manager.site_manager.bench_config import BenchConfig, FMBenchEnvType 

25from frappe_manager.site_manager.site import Bench 

26 

27 

28class BenchService: 

29 """ 

30 Service layer for bench operations. 

31 

32 Provides high-level operations for managing benches without exposing 

33 internal implementation details to the CLI layer. 

34 

35 Attributes: 

36 benches_directory: Root directory containing all benches 

37 services: Global services manager instance 

38 verbose: Whether to enable verbose output 

39 

40 Example: 

41 >>> service = BenchService(CLI_BENCHES_DIRECTORY, services_manager) 

42 >>> bench = service.get_bench("mysite.localhost") 

43 >>> benches = service.list_benches() 

44 """ 

45 

46 def __init__( 

47 self, 

48 benches_directory: Path, 

49 services: ServicesManager, 

50 verbose: bool = False, 

51 output_handler: OutputHandler | None = None, 

52 ): 

53 """ 

54 Initialize bench service. 

55 

56 Args: 

57 benches_directory: Path to directory containing benches 

58 services: Global services manager 

59 verbose: Enable verbose output 

60 output_handler: Handler for output operations 

61 """ 

62 self.benches_directory = benches_directory 

63 self.services = services 

64 self.verbose = verbose 

65 self.output = output_handler or RichOutputHandler() 

66 self.logger = log.get_logger() 

67 

68 def get_bench( 

69 self, 

70 bench_name: str, 

71 workers_check: bool = True, 

72 admin_tools_check: bool = True, 

73 ) -> Bench: 

74 """ 

75 Get a bench instance by name. 

76 

77 This is a convenience method that wraps Bench.get_object() with 

78 the service's configuration. 

79 

80 Args: 

81 bench_name: Name of the bench to retrieve 

82 workers_check: Whether to check worker status 

83 admin_tools_check: Whether to check admin tools status 

84 

85 Returns: 

86 Bench instance 

87 

88 Raises: 

89 FileNotFoundError: If bench config not found 

90 

91 Example: 

92 >>> bench = service.get_bench("mysite.localhost") 

93 >>> bench.start() 

94 """ 

95 return Bench.get_object( 

96 bench_name=bench_name, 

97 services=self.services, 

98 workers_check=workers_check, 

99 admin_tools_check=admin_tools_check, 

100 verbose=self.verbose, 

101 output_handler=self.output, 

102 ) 

103 

104 def create_bench( 

105 self, 

106 bench_name: str, 

107 bench_config: BenchConfig, 

108 is_template: bool = False, 

109 ) -> Bench: 

110 """ 

111 Create a new bench. 

112 

113 Handles all the setup for creating a new bench including: 

114 - Creating directory structure 

115 - Initializing docker compose files 

116 - Creating bench instance 

117 - Running bench creation process 

118 

119 Args: 

120 bench_name: Name for the new bench 

121 bench_config: Configuration for the bench 

122 is_template: Whether to create a template bench 

123 

124 Returns: 

125 Created Bench instance 

126 

127 Example: 

128 >>> config = BenchConfig(name="site.localhost", ...) 

129 >>> bench = service.create_bench("site.localhost", config) 

130 """ 

131 bench_path = self.benches_directory / bench_name 

132 compose_path = bench_path / "docker-compose.yml" 

133 

134 compose_file_manager = ComposeFile(compose_path) 

135 docker_client = DockerClient(compose_file_path=compose_path, output=self.output) 

136 

137 from frappe_manager.logger.context import LoggerContext 

138 from frappe_manager.logger.contextual import ContextualLogger 

139 

140 bench_logger = ContextualLogger(self.logger, context=LoggerContext(bench=bench_name, operation="create")) 

141 

142 bench = Bench( 

143 logger=bench_logger, 

144 path=bench_path, 

145 name=bench_name, 

146 bench_config=bench_config, 

147 compose_file_manager=compose_file_manager, 

148 docker_client=docker_client, 

149 services=self.services, 

150 verbose=self.verbose, 

151 output_handler=self.output, 

152 ) 

153 

154 bench.create(is_template_bench=is_template) 

155 return bench 

156 

157 def delete_bench( 

158 self, 

159 bench_name: str, 

160 yes: bool = False, 

161 delete_db_from_global_db: bool | None = None, 

162 ) -> bool: 

163 try: 

164 bench = self.get_bench(bench_name, workers_check=False, admin_tools_check=False) 

165 except FileNotFoundError: 

166 bench = self._create_cleanup_bench(bench_name) 

167 

168 if yes: 

169 from frappe_manager.output_manager import spinner 

170 

171 with spinner(self.output, "Removing bench"): 

172 try: 

173 bench.remove_certificate() 

174 except Exception as e: 

175 self.output.warning(str(e)) 

176 

177 try: 

178 self._handle_database_deletion(bench, delete_db_from_global_db) 

179 except Exception as e: 

180 self.output.warning(f"Database deletion failed: {e!s}") 

181 self.output.warning("Continuing with bench removal...") 

182 

183 bench.remove_containers_and_dirs() 

184 return True 

185 return bench.remove_bench(delete_db_from_global_db=delete_db_from_global_db) 

186 

187 def discover_benches(self) -> dict[str, Path]: 

188 """ 

189 Discover all benches in the benches directory. 

190 

191 Returns: 

192 Dictionary mapping bench names to their docker-compose.yml paths 

193 

194 Example: 

195 >>> benches = service.discover_benches() 

196 >>> print(f"Found {len(benches)} benches") 

197 """ 

198 benches = {} 

199 

200 if not self.benches_directory.exists(): 

201 return benches 

202 

203 for bench_dir in self.benches_directory.iterdir(): 

204 if not bench_dir.is_dir(): 

205 continue 

206 

207 bench_name = bench_dir.name 

208 compose_file = bench_dir / "docker-compose.yml" 

209 

210 if compose_file.exists(): 

211 benches[bench_name] = compose_file 

212 

213 return benches 

214 

215 def get_bench_names(self) -> list[str]: 

216 """ 

217 Get list of all bench names. 

218 

219 Returns: 

220 List of bench names 

221 

222 Example: 

223 >>> names = service.get_bench_names() 

224 >>> for name in names: 

225 ... bench = service.get_bench(name) 

226 """ 

227 return list(self.discover_benches().keys()) 

228 

229 def list_benches_table(self) -> Table: 

230 """ 

231 Generate a formatted table of all benches. 

232 

233 Returns: 

234 Rich Table object with bench information 

235 

236 Example: 

237 >>> table = service.list_benches_table() 

238 >>> self.output.print(table) 

239 """ 

240 self.output.change_head("Generating bench list") 

241 

242 bench_dict = self.discover_benches() 

243 

244 if not bench_dict: 

245 self.output.stop() 

246 self.output.print( 

247 "Seems like you haven't created any sites yet. " 

248 "To create a bench, use the command: 'fm create <benchname>'.", 

249 emoji_code=":white_check_mark:", 

250 ) 

251 table = Table(show_lines=True, show_header=True, highlight=True) 

252 table.add_column("Site") 

253 table.add_column("Status", vertical="middle") 

254 table.add_column("Path") 

255 return table 

256 

257 table = Table(show_lines=True, show_header=True, highlight=True) 

258 table.add_column("Site") 

259 table.add_column("Status", vertical="middle") 

260 table.add_column("Path") 

261 

262 for bench_name in bench_dict.keys(): 

263 try: 

264 bench = self.get_bench(bench_name, workers_check=False, admin_tools_check=False) 

265 

266 row_data = f"[link=http://{bench.name}]{bench.name}[/link]" 

267 path_data = f"[link=file://{bench.path}]{bench.path}[/link]" 

268 

269 status_color = "white" 

270 status_msg = "Inactive" 

271 

272 if bench.running: 

273 status_color = "green" 

274 status_msg = "Active" 

275 

276 status_data = f"[{status_color}]{status_msg}[/{status_color}]" 

277 

278 table.add_row(row_data, status_data, path_data, style=f"{status_color}") 

279 self.output.update_live(table, padding=(0, 0, 0, 0)) 

280 

281 except FileNotFoundError as e: 

282 self.output.warning(f"[red][bold]{bench_name}[/bold][/red] : Bench config not found at {e.filename}") 

283 

284 self.output.stop() 

285 return table 

286 

287 def _create_cleanup_bench(self, bench_name: str) -> Bench: 

288 """ 

289 Create a minimal bench instance for cleanup purposes. 

290 

291 Used when bench config is missing but we need to clean up containers/files. 

292 

293 Args: 

294 bench_name: Name of bench to clean up 

295 

296 Returns: 

297 Minimal Bench instance 

298 """ 

299 import os 

300 

301 bench_path = self.benches_directory / bench_name 

302 compose_path = bench_path / "docker-compose.yml" 

303 

304 compose_file_manager = ComposeFile(compose_path) 

305 docker_client = DockerClient(compose_file_path=compose_path, output=self.output) 

306 

307 fake_config = BenchConfig( 

308 name=bench_name, 

309 userid=os.getuid(), 

310 usergroup=os.getgid(), 

311 apps_list=[], 

312 developer_mode=False, 

313 admin_tools=False, 

314 admin_pass="pass", 

315 environment_type=FMBenchEnvType.dev, 

316 root_path=bench_path / "bench_config.toml", 

317 admin_tools_username=None, 

318 admin_tools_password=None, 

319 github_token=None, 

320 use_uv=True, 

321 python_version=None, 

322 node_version=None, 

323 db_name=None, 

324 migration_state=None, 

325 ) 

326 

327 from frappe_manager.logger.context import LoggerContext 

328 from frappe_manager.logger.contextual import ContextualLogger 

329 

330 cleanup_logger = ContextualLogger(self.logger, context=LoggerContext(bench=bench_name, operation="cleanup")) 

331 

332 return Bench( 

333 logger=cleanup_logger, 

334 path=bench_path, 

335 name=bench_name, 

336 bench_config=fake_config, 

337 compose_file_manager=compose_file_manager, 

338 docker_client=docker_client, 

339 services=self.services, 

340 workers_check=False, 

341 admin_tools_check=False, 

342 verbose=self.verbose, 

343 output_handler=self.output, 

344 ) 

345 

346 def _is_using_global_db(self, bench: Bench) -> bool: 

347 """ 

348 Check if bench is using FM's managed global-db service. 

349 

350 Args: 

351 bench: Bench instance to check 

352 

353 Returns: 

354 True if bench uses global-db, False otherwise 

355 """ 

356 try: 

357 db_info = bench.database.get_connection_info() 

358 db_host = db_info.get("host", "") 

359 

360 return db_host == "global-db" 

361 except Exception: 

362 return False 

363 

364 def _handle_database_deletion(self, bench: Bench, delete_db_from_global_db: bool | None): 

365 """ 

366 Handle database deletion based on user preference and database location. 

367 

368 Args: 

369 bench: Bench instance 

370 delete_db_from_global_db: User preference for database deletion. 

371 None = prompt if using global-db 

372 True = delete from global-db 

373 False = don't delete from global-db 

374 """ 

375 is_global_db = self._is_using_global_db(bench) 

376 

377 if not is_global_db: 

378 self.output.print("Bench is not using FM's managed global-db. Skipping database deletion") 

379 return 

380 

381 should_delete = delete_db_from_global_db 

382 

383 if should_delete is None: 

384 params = { 

385 "prompt": f"🗄️ Do you want to remove the database '[bold]{bench.name}[/bold]' from global-db?", 

386 "choices": ["yes", "no"], 

387 "default": "yes", 

388 "required_flag": "--delete-db-from-global-db or --no-delete-db-from-global-db", 

389 } 

390 choice = self.output.prompt_ask(**params) 

391 should_delete = choice == "yes" 

392 

393 if should_delete: 

394 bench.remove_database_and_user() 

395 else: 

396 self.output.print("Skipping database deletion from global-db")