Coverage for src / ezqt_app / services / bootstrap / sequence.py: 70.40%

97 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 07:07 +0000

1# /////////////////////////////////////////////////////////////// 

2# SERVICES.BOOTSTRAP.SEQUENCE - Initialization sequence orchestrator 

3# Project: ezqt_app 

4# /////////////////////////////////////////////////////////////// 

5 

6"""Explicit step-by-step initialization sequence with status tracking.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14from collections.abc import Callable 

15 

16# Local imports 

17from ...domain.errors import EzQtError 

18from ...domain.results import InitResult, InitStepResult 

19from ...domain.results.result_error import ResultError 

20from ...utils.printer import get_printer 

21from .contracts.options import InitOptions 

22from .contracts.steps import InitStep, StepStatus 

23 

24 

25# /////////////////////////////////////////////////////////////// 

26# CLASSES 

27# /////////////////////////////////////////////////////////////// 

28class InitializationSequence: 

29 """Orchestrates the ordered initialization steps for EzQt_App. 

30 

31 Each step is registered with a name, description, callable and a 

32 *required* flag. If a required step fails the sequence stops 

33 immediately; non-required steps are allowed to fail silently. 

34 """ 

35 

36 def __init__(self, options: InitOptions | None = None) -> None: 

37 self.options = (options or InitOptions()).resolve() 

38 self.steps: list[InitStep] = [] 

39 self.current_step: InitStep | None = None 

40 self.printer = get_printer(self.options.verbose) 

41 

42 # Register the resolved bin path early so all runtime services 

43 # (themes, fonts, config, translations) use the correct directory. 

44 if self.options.bin_path is not None: 44 ↛ 49line 44 didn't jump to line 49 because the condition on line 44 was always true

45 from ...utils.runtime_paths import set_bin_path 

46 

47 set_bin_path(self.options.bin_path) 

48 

49 self._setup_steps() 

50 

51 # ------------------------------------------------------------------ 

52 # Error code 

53 # ------------------------------------------------------------------ 

54 

55 def _error_code_for_step(self, step_name: str) -> str: 

56 """Return a normalized error code for a failed step.""" 

57 slug = step_name.strip().lower().replace(" ", "_") 

58 return f"bootstrap.step.{slug}.failed" 

59 

60 # ------------------------------------------------------------------ 

61 # Default steps 

62 # ------------------------------------------------------------------ 

63 

64 def _setup_steps(self) -> None: 

65 """Register the default EzQt_App boot steps.""" 

66 from ..application.app_service import AppService 

67 from ..application.file_service import FileService 

68 from .startup_config import StartupConfig 

69 

70 options = self.options 

71 

72 self.add_step( 

73 name="Configure Startup", 

74 description="Configure UTF-8 encoding, locale, and environment variables", 

75 function=lambda: StartupConfig().configure(options.project_root), 

76 required=True, 

77 ) 

78 self.add_step( 

79 name="Create Directories", 

80 description="Create necessary directories for assets and config", 

81 function=lambda: FileService( 

82 base_path=options.project_root, 

83 bin_path=options.bin_path, 

84 overwrite_policy=options.overwrite_policy.value, 

85 ).make_assets_binaries(), 

86 required=True, 

87 ) 

88 self.add_step( 

89 name="Copy Configurations", 

90 description="Copy package configuration files to project bin/config directory", 

91 function=lambda: AppService.copy_package_configs_to_project(), 

92 required=False, 

93 ) 

94 self.add_step( 

95 name="Generate Files", 

96 description="Generate required configuration and resource files", 

97 function=lambda: AppService.make_required_files( 

98 mk_theme=options.mk_theme, 

99 mk_config=options.mk_config, 

100 mk_translations=options.mk_translations, 

101 base_path=options.project_root, 

102 bin_path=options.bin_path, 

103 overwrite_policy=options.overwrite_policy.value, 

104 ), 

105 required=True, 

106 ) 

107 self.add_step( 

108 name="Check Requirements", 

109 description="Verify that all required assets and dependencies are available", 

110 function=lambda: ( 

111 AppService.check_assets_requirements( 

112 base_path=options.project_root, 

113 bin_path=options.bin_path, 

114 overwrite_policy=options.overwrite_policy.value, 

115 ) 

116 if options.build_resources 

117 else None 

118 ), 

119 required=True, 

120 ) 

121 self.add_step( 

122 name="Load Resources", 

123 description="Register compiled Qt resources (bin/resources_rc.py)", 

124 function=lambda: ( 

125 __import__( 

126 "ezqt_app.shared.resources", 

127 fromlist=["load_runtime_rc"], 

128 ).load_runtime_rc() 

129 if options.build_resources 

130 else None 

131 ), 

132 required=False, 

133 ) 

134 

135 # ------------------------------------------------------------------ 

136 # Step management 

137 # ------------------------------------------------------------------ 

138 

139 def add_step( 

140 self, 

141 name: str, 

142 description: str, 

143 function: Callable[[], None], 

144 required: bool = True, 

145 ) -> None: 

146 """Append a new step to the sequence.""" 

147 self.steps.append( 

148 InitStep( 

149 name=name, description=description, function=function, required=required 

150 ) 

151 ) 

152 

153 # ------------------------------------------------------------------ 

154 # Execution 

155 # ------------------------------------------------------------------ 

156 

157 def execute(self, verbose: bool = True) -> InitResult: 

158 """Run all registered steps in order. 

159 

160 Returns 

161 ------- 

162 InitResult 

163 Typed aggregate result with step-level execution details. 

164 """ 

165 import time 

166 

167 if verbose: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 self.printer.custom_print( 

169 "~ [Initializer] Starting EzQt_App Initialization Sequence", 

170 color="MAGENTA", 

171 ) 

172 self.printer.raw_print("...") 

173 

174 start_time = time.time() 

175 successful_steps = 0 

176 failed_steps = 0 

177 skipped_steps = 0 

178 first_error: Exception | None = None 

179 first_failed_step: str | None = None 

180 

181 for step in self.steps: 

182 self.current_step = step 

183 step_start = time.time() 

184 step.status = StepStatus.RUNNING 

185 

186 try: 

187 step.function() 

188 step.status = StepStatus.SUCCESS 

189 step.duration = time.time() - step_start 

190 successful_steps += 1 

191 except Exception as e: 

192 step.status = StepStatus.FAILED 

193 step.error_message = str(e) 

194 step.duration = time.time() - step_start 

195 failed_steps += 1 

196 if first_error is None: 196 ↛ 200line 196 didn't jump to line 200 because the condition on line 196 was always true

197 first_error = e 

198 first_failed_step = step.name 

199 

200 if verbose: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true

201 self.printer.error( 

202 f"[Initializer] Step failed ({step.duration:.2f}s): {e}" 

203 ) 

204 

205 if step.required: 

206 if verbose: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 self.printer.error( 

208 f"[Initializer] Initialization failed at required step: {step.name}" 

209 ) 

210 break 

211 

212 total_time = time.time() - start_time 

213 summary = InitResult( 

214 success=(failed_steps == 0), 

215 total_steps=len(self.steps), 

216 successful=successful_steps, 

217 failed=failed_steps, 

218 skipped=skipped_steps, 

219 total_time=total_time, 

220 steps=[ 

221 InitStepResult( 

222 name=step.name, 

223 description=step.description, 

224 required=step.required, 

225 status=step.status.value, 

226 error_message=step.error_message, 

227 duration=step.duration, 

228 ) 

229 for step in self.steps 

230 ], 

231 ) 

232 

233 if first_error is not None and first_failed_step is not None: 

234 if isinstance(first_error, EzQtError): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 summary.error = ResultError( 

236 code=first_error.code, 

237 message=first_error.message, 

238 context=first_error.context, 

239 ) 

240 else: 

241 summary.error = ResultError( 

242 code=self._error_code_for_step(first_failed_step), 

243 message=str(first_error), 

244 context={ 

245 "step": first_failed_step, 

246 "error_type": type(first_error).__name__, 

247 }, 

248 ) 

249 

250 if verbose: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true

251 self._print_summary(summary) 

252 

253 return summary 

254 

255 def _print_summary(self, summary: InitResult) -> None: 

256 self.printer.raw_print("...") 

257 if summary.success: 

258 self.printer.custom_print( 

259 "~ [Initializer] Initialization completed successfully!", 

260 color="MAGENTA", 

261 ) 

262 else: 

263 self.printer.custom_print( 

264 "~ [Initializer] Initialization failed!", color="MAGENTA" 

265 ) 

266 

267 # ------------------------------------------------------------------ 

268 # Introspection helpers 

269 # ------------------------------------------------------------------ 

270 

271 def get_step_status(self, step_name: str) -> StepStatus | None: 

272 for step in self.steps: 

273 if step.name == step_name: 273 ↛ 272line 273 didn't jump to line 272 because the condition on line 273 was always true

274 return step.status 

275 return None 

276 

277 def get_failed_steps(self) -> list[InitStep]: 

278 return [s for s in self.steps if s.status == StepStatus.FAILED] 

279 

280 def get_successful_steps(self) -> list[InitStep]: 

281 return [s for s in self.steps if s.status == StepStatus.SUCCESS] 

282 

283 def reset(self) -> None: 

284 for step in self.steps: 

285 step.status = StepStatus.PENDING 

286 step.error_message = None 

287 step.duration = None 

288 self.current_step = None