Coverage for src/features/menus.py: 100.00%

141 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-18 15:02 +0000

1#!/usr/bin/env python3 

2 

3# Standard libraries 

4from argparse import Namespace 

5from typing import Any, Dict, List, Optional 

6 

7# Components 

8from ..models.flags import Flags 

9from ..models.pipelines import Pipeline 

10from ..package.bundle import Bundle 

11from ..prints.colors import Colors 

12from ..prints.themes import Themes 

13from ..system.platform import Platform 

14from ..types.lists import Lists 

15from .pipelines import PipelinesFeature 

16 

17# MenusFeature class 

18class MenusFeature: 

19 

20 # Members 

21 __pipeline: Optional[Pipeline] 

22 __options: Namespace 

23 

24 # Constructor 

25 def __init__( 

26 self, 

27 pipeline: Optional[Pipeline], 

28 options: Namespace, 

29 ) -> None: 

30 

31 # Prepare jobs 

32 self.__pipeline = pipeline 

33 

34 # Prepare options 

35 self.__options = options 

36 

37 # Configure, pylint: disable=too-many-branches,too-many-locals,too-many-statements 

38 @staticmethod 

39 def configure(configurations: Dict[str, Any]) -> Dict[str, str]: 

40 

41 # Variables 

42 result = {} 

43 

44 # Header 

45 print(' ') 

46 print( 

47 f' {Colors.GREEN}===[ {Colors.YELLOW}Configurations menu' \ 

48 f' {Colors.GREEN}]==={Colors.RESET}' 

49 ) 

50 print(' ') 

51 Platform.flush() 

52 

53 # Walk through configurations 

54 for variable in configurations: 

55 

56 # Variables 

57 variable_choices: List[Dict[str, str]] = [] 

58 variable_default: str = '' 

59 variable_index: int = 0 

60 variable_set: bool = False 

61 variable_values: List[str] = [] 

62 

63 # Extract configuration fields 

64 variable_node = configurations[variable] 

65 variable_help = variable_node['help'] 

66 variable_type = variable_node['type'] 

67 

68 # Prepare configuration selection 

69 configuration_message: str = f'[{variable}] {variable_help}:' 

70 configuration_type = None 

71 

72 # Extract configuration set 

73 if 'set' in variable_node and isinstance(variable_node['set'], bool): 

74 variable_set = variable_node['set'] 

75 if 'default' in variable_node: 

76 variable_default = variable_node['default'] 

77 

78 # Parse configuration types: boolean 

79 if variable_type == 'boolean': 

80 if 'default' in variable_node and variable_node['default'] in [ 

81 False, 'false' 

82 ]: 

83 variable_values = ['false', 'true'] 

84 else: 

85 variable_values = ['true', 'false'] 

86 if not variable_set: 

87 variable_default = variable_values[0] 

88 for choice in variable_values: 

89 variable_index += 1 

90 variable_choices += [{ 

91 # 'key': str(variable_index), 

92 'name': f'{choice}', 

93 'value': choice 

94 }] 

95 configuration_type = 'select' 

96 

97 # Parse configuration types: choice 

98 elif variable_type == 'choice': 

99 variable_values = variable_node['values'] 

100 if not variable_set: 

101 variable_default = variable_values[0] 

102 for choice in variable_values: 

103 variable_index += 1 

104 variable_choices += [{ 

105 'key': str(variable_index), 

106 'name': f'{choice}', 

107 'value': choice 

108 }] 

109 configuration_type = 'select' 

110 

111 # Parse configuration types: input 

112 elif variable_type == 'input': 

113 configuration_type = 'text' 

114 if 'default' in variable_node and variable_node[ 

115 'default'] and not variable_set: 

116 variable_default = variable_node['default'] 

117 

118 # Parse configuration types: number 

119 elif variable_type == 'number': 

120 configuration_type = 'number' 

121 if 'default' in variable_node and variable_node[ 

122 'default'] and not variable_set: 

123 variable_default = str(variable_node['default']) 

124 

125 # Use configuration defaults 

126 if not Platform.IS_TTY_STDIN or variable_set: 

127 result[variable] = variable_default 

128 print( 

129 f' {Colors.YELLOW}{configuration_message}' \ 

130 f' {Colors.CYAN}{result[variable]}{Colors.RESET}' 

131 ) 

132 

133 # Request configuration selection 

134 else: 

135 

136 # Modules libraries 

137 from prompt_toolkit.styles.style import Style # pylint: disable=import-outside-toplevel 

138 from questionary import ( # pylint: disable=import-outside-toplevel 

139 select as questionary_select, text as questionary_text, 

140 ) 

141 

142 # User interactive request 

143 if configuration_type == 'select': 

144 answers = questionary_select( 

145 message=configuration_message, 

146 choices=variable_choices, 

147 qmark='', 

148 pointer=Themes.POINTER, 

149 style=Style.from_dict(Themes.configuration_style()), 

150 use_indicator=False, 

151 use_shortcuts=False, 

152 use_arrow_keys=True, 

153 use_jk_keys=True, 

154 show_selected=False, 

155 ).ask() 

156 elif configuration_type == 'number': 

157 answers = questionary_text( 

158 message=configuration_message, 

159 default=variable_default, 

160 qmark='', 

161 style=Style.from_dict(Themes.configuration_style()), 

162 multiline=False, 

163 validate=lambda t: ( # 

164 (( 

165 t.isdigit() or (t.startswith('-') and t[1:].isdigit()) # 

166 ) and ( # 

167 t == '0' or # 

168 t == '-0' or # 

169 not t.lstrip('-').startswith('0') # 

170 )) or # 

171 ' [ERROR] Integer number input required (without leading zeros)' # 

172 ), 

173 ).ask() 

174 elif configuration_type == 'text': 

175 answers = questionary_text( 

176 message=configuration_message, 

177 default=variable_default, 

178 qmark='', 

179 style=Style.from_dict(Themes.configuration_style()), 

180 multiline=False, 

181 ).ask() 

182 else: # pragma: no cover 

183 answers = None 

184 if answers is None: # pragma: no cover 

185 raise KeyboardInterrupt 

186 result[variable] = str(answers) 

187 

188 # Footer 

189 print(' ') 

190 Platform.flush() 

191 

192 # Result 

193 return result 

194 

195 # Select, pylint: disable=too-many-branches,too-many-statements 

196 def select(self) -> bool: 

197 

198 # Modules libraries 

199 from prompt_toolkit.styles.style import Style # pylint: disable=import-outside-toplevel 

200 from questionary import ( # pylint: disable=import-outside-toplevel 

201 checkbox as questionary_checkbox, select as questionary_select, Choice as 

202 questionary_Choice, Separator as questionary_Separator, 

203 ) 

204 

205 # Variables 

206 default_check: bool = self.__options.all 

207 jobs_available: bool = False 

208 jobs_choices: List[questionary_Choice | questionary_Separator] = [] 

209 result: bool = True 

210 stage: str = '' 

211 

212 # Stages groups 

213 assert self.__pipeline is not None 

214 for job in self.__pipeline.jobs: 

215 

216 # Preset names 

217 job_preset: bool = False 

218 if self.__options.presets: 

219 if self.__options.names and Lists.match( 

220 self.__options.names, 

221 self.__pipeline.jobs[job].stage 

222 if self.__options.pipeline else job, 

223 ignore_case=self.__options.ignore_case, 

224 no_regex=self.__options.no_regex, 

225 ): 

226 job_preset = True 

227 

228 # Filter names 

229 elif self.__options.names: 

230 

231 # Filter jobs list, pylint: disable=duplicate-code 

232 if not self.__options.pipeline and not Lists.match( 

233 self.__options.names, 

234 job, 

235 ignore_case=self.__options.ignore_case, 

236 no_regex=self.__options.no_regex, 

237 ): 

238 continue 

239 

240 # Filter stages list 

241 if self.__options.pipeline and not Lists.match( 

242 self.__options.names, 

243 self.__pipeline.jobs[job].stage, 

244 ignore_case=self.__options.ignore_case, 

245 no_regex=self.__options.no_regex, 

246 ): 

247 continue 

248 

249 # Stages separator 

250 if stage != self.__pipeline.jobs[job].stage: 

251 stage = self.__pipeline.jobs[job].stage 

252 jobs_choices += [questionary_Separator(f'\n Stage {stage}:')] 

253 

254 # Initial job details 

255 job_details_list: List[str] = [] 

256 job_details_string: str = '' 

257 tags: str = '' 

258 

259 # Disabled jobs 

260 disabled: Optional[str] = None 

261 if self.__pipeline.jobs[job].when in ['manual'] and not Flags.check_bool( 

262 option=self.__options.manual, 

263 flag=Bundle.FLAG_MANUAL, 

264 variables=self.__pipeline.jobs[job].variables, 

265 default=False, 

266 ): 

267 disabled = 'Manual' 

268 elif self.__pipeline.jobs[job].script: 

269 if self.__pipeline.jobs[job].when == 'manual': 

270 job_details_list += ['Manual'] 

271 elif self.__pipeline.jobs[job].when == 'on_failure': 

272 job_details_list += ['On failure'] 

273 jobs_available = True 

274 

275 # Parser disabled jobs 

276 if self.__pipeline.jobs[job].options.disabled: 

277 disabled = self.__pipeline.jobs[job].options.disabled 

278 

279 # Failure allowed jobs 

280 if self.__pipeline.jobs[job].allow_failure: 

281 job_details_list += ['Failure allowed'] 

282 

283 # Register job tags 

284 job_tags: Optional[List[str]] = self.__pipeline.jobs[job].tags 

285 if job_tags: 

286 tags = f" [{','.join(job_tags)}]" 

287 

288 # Prepare job details 

289 if job_details_list: 

290 job_details_string = f" ({', '.join(job_details_list)})" 

291 

292 # Job choices 

293 jobs_choices += [ 

294 questionary_Choice( 

295 title=f'{self.__pipeline.jobs[job].name}{tags}{job_details_string}', 

296 value=job, 

297 disabled=disabled, 

298 checked=default_check or (job_preset and not disabled), 

299 shortcut_key=True, 

300 ) 

301 ] 

302 

303 # Request jobs selection 

304 if jobs_choices and jobs_available: 

305 try: 

306 if self.__options.list: 

307 answers = questionary_select( 

308 message='===[ Jobs selector ]===', 

309 choices=jobs_choices, 

310 qmark='', 

311 pointer=Themes.POINTER, 

312 style=Style.from_dict(Themes.configuration_style()), 

313 use_indicator=False, 

314 use_shortcuts=False, 

315 use_arrow_keys=True, 

316 use_jk_keys=True, 

317 show_selected=False, 

318 ).ask() 

319 else: 

320 answers = questionary_checkbox( 

321 message='===[ Jobs selector ]===', 

322 choices=jobs_choices, 

323 qmark='', 

324 pointer=Themes.POINTER, 

325 style=Style.from_dict(Themes.checkbox_style()), 

326 use_arrow_keys=True, 

327 use_jk_keys=True, 

328 ).ask() 

329 except KeyboardInterrupt: # pragma: no cover 

330 answers = None 

331 

332 # No jobs found 

333 else: 

334 print( 

335 f' {Colors.GREEN}{Bundle.NAME}: {Colors.RED}ERROR:' \ 

336 f' {Colors.BOLD}No jobs found for selection{Colors.RESET}' 

337 ) 

338 answers = None 

339 

340 # Parse jobs selection 

341 if answers: 

342 if self.__options.list: 

343 self.__options.names = [answers] 

344 else: 

345 self.__options.names = answers 

346 else: 

347 self.__options.names = [] 

348 

349 # Drop pipeline mode for jobs 

350 self.__options.pipeline = False 

351 

352 # Launch selected jobs only 

353 self.__options.no_regex = True 

354 

355 # Footer 

356 print(' ') 

357 print(' ') 

358 Platform.flush() 

359 

360 # Launch jobs 

361 if self.__options.names: 

362 result = PipelinesFeature( 

363 self.__pipeline, 

364 self.__options, 

365 ).launch() 

366 

367 # Result 

368 return result