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
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-18 15:02 +0000
1#!/usr/bin/env python3
3# Standard libraries
4from argparse import Namespace
5from typing import Any, Dict, List, Optional
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
17# MenusFeature class
18class MenusFeature:
20 # Members
21 __pipeline: Optional[Pipeline]
22 __options: Namespace
24 # Constructor
25 def __init__(
26 self,
27 pipeline: Optional[Pipeline],
28 options: Namespace,
29 ) -> None:
31 # Prepare jobs
32 self.__pipeline = pipeline
34 # Prepare options
35 self.__options = options
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]:
41 # Variables
42 result = {}
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()
53 # Walk through configurations
54 for variable in configurations:
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] = []
63 # Extract configuration fields
64 variable_node = configurations[variable]
65 variable_help = variable_node['help']
66 variable_type = variable_node['type']
68 # Prepare configuration selection
69 configuration_message: str = f'[{variable}] {variable_help}:'
70 configuration_type = None
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']
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'
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'
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']
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'])
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 )
133 # Request configuration selection
134 else:
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 )
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)
188 # Footer
189 print(' ')
190 Platform.flush()
192 # Result
193 return result
195 # Select, pylint: disable=too-many-branches,too-many-statements
196 def select(self) -> bool:
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 )
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 = ''
212 # Stages groups
213 assert self.__pipeline is not None
214 for job in self.__pipeline.jobs:
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
228 # Filter names
229 elif self.__options.names:
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
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
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}:')]
254 # Initial job details
255 job_details_list: List[str] = []
256 job_details_string: str = ''
257 tags: str = ''
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
275 # Parser disabled jobs
276 if self.__pipeline.jobs[job].options.disabled:
277 disabled = self.__pipeline.jobs[job].options.disabled
279 # Failure allowed jobs
280 if self.__pipeline.jobs[job].allow_failure:
281 job_details_list += ['Failure allowed']
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)}]"
288 # Prepare job details
289 if job_details_list:
290 job_details_string = f" ({', '.join(job_details_list)})"
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 ]
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
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
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 = []
349 # Drop pipeline mode for jobs
350 self.__options.pipeline = False
352 # Launch selected jobs only
353 self.__options.no_regex = True
355 # Footer
356 print(' ')
357 print(' ')
358 Platform.flush()
360 # Launch jobs
361 if self.__options.names:
362 result = PipelinesFeature(
363 self.__pipeline,
364 self.__options,
365 ).launch()
367 # Result
368 return result