Coverage for src/types/yaml.py: 100.00%
218 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 copy import deepcopy
5from io import TextIOWrapper
6from re import fullmatch as regex_fullmatch, sub as regex_sub
7from sys import maxsize
8from typing import Any, Dict, List, Optional
10# Modules libraries
11from yaml import dump as yaml_dump, Dumper as yaml_Dumper, load_all as yaml_load_all
12from yaml import SafeLoader as yaml_SafeLoader, YAMLError as yaml_Error
13from yaml.nodes import SequenceNode as yaml_SequenceNode
15# YAML class
16class YAML:
18 # Data type
19 Data = Dict[str, Any]
21 # Error type
22 Error = yaml_Error
24 # Input type, pylint: disable=too-few-public-methods
25 class Input:
27 # Constants
28 TYPE_ARRAY: str = 'array'
29 TYPE_BOOLEAN: str = 'boolean'
30 TYPE_NUMBER: str = 'number'
31 TYPE_STRING: str = 'string'
33 # Members
34 default: Optional[float | int | str] = None
35 description: Optional[str] = None
36 options: Optional[List[str]] = None
37 regex: Optional[str] = None
38 value: Optional[float | int | str] = None
39 value_type: str = TYPE_STRING
41 # Set description
42 def set_description(self, description: str) -> None:
43 self.description = description
45 # Set type
46 def set_type(self, value: str) -> None:
47 if value not in [
48 # YAML.Input.TYPE_ARRAY,
49 YAML.Input.TYPE_BOOLEAN,
50 YAML.Input.TYPE_NUMBER,
51 YAML.Input.TYPE_STRING,
52 ]:
53 raise TypeError(f'\'{value}\': Input type unknown value')
54 self.value_type = value
56 # Set options
57 def set_options(self, options: List[Any]) -> None:
58 if not options:
59 raise ValueError('Options cannot be empty for input')
60 if self.value_type not in [YAML.Input.TYPE_NUMBER, YAML.Input.TYPE_STRING]:
61 raise TypeError(
62 f'\'{self.value_type}\': Options can only be used with string and number inputs'
63 )
64 for option in options:
65 if self.value_type == YAML.Input.TYPE_NUMBER and not isinstance(
66 option, (float, int)):
67 raise TypeError(
68 f'\'{option}\': Options can only be numbers for inputs')
69 if self.value_type in YAML.Input.TYPE_STRING and not isinstance(
70 option, str):
71 raise TypeError(
72 f'\'{option}\': Options can only be strings for inputs')
73 if self.regex and not regex_fullmatch(self.regex, option):
74 raise ValueError(f'\'{option}\': Option value does not match'
75 f' required RegEx pattern \'{self.regex}\'')
76 self.options = options[:]
78 # Set regex
79 def set_regex(
80 self,
81 regex: str,
82 ) -> None:
83 if self.value_type not in [YAML.Input.TYPE_STRING]:
84 raise TypeError(
85 f'\'{self.value_type}\': RegEx validation can only be used with string inputs'
86 )
87 self.regex = regex
89 # Set default
90 def set_default(
91 self,
92 default: bool | float | int | str,
93 ) -> None:
94 self.default = self.parse_value(default)
96 # Validate input default
97 if self.options and self.default not in self.options:
98 raise ValueError(
99 f'\'{self.default}\': Default value not allowed in options'
100 f': {", ".join(self.options)})')
101 if self.regex and not regex_fullmatch(self.regex, str(self.default)):
102 raise ValueError(f'\'{self.default}\': Default value does not match'
103 f' required RegEx pattern \'{self.regex}\'')
105 # Set value
106 def set_value(
107 self,
108 value: bool | float | int | str,
109 ) -> None:
110 self.value = self.parse_value(value)
112 # Validate input value
113 if self.options and self.value not in self.options:
114 raise ValueError(
115 f'\'{self.value}\': Value not allowed in options: {", ".join(self.options)})'
116 )
117 if self.regex and not regex_fullmatch(self.regex, str(self.value)):
118 raise ValueError(f'\'{self.value}\': Value does not match'
119 f' required RegEx pattern \'{self.regex}\'')
121 # Parse value, pylint: disable=too-many-branches,too-many-return-statements
122 def parse_value(
123 self,
124 value: bool | float | int | str,
125 ) -> bool | float | int | str:
127 # Set boolean value
128 if self.value_type == YAML.Input.TYPE_BOOLEAN:
129 if isinstance(value, bool):
130 return str(value).lower()
131 if isinstance(value, int) and value in [1]:
132 return 'true'
133 if isinstance(value, int) and value in [0]:
134 return 'false'
135 if isinstance(value, str) and value.lower() in ['true', '1']:
136 return 'true'
137 if isinstance(value, str) and value.lower() in ['false', '0']:
138 return 'false'
139 raise ValueError(f'\'{value}\': Value is not a boolean')
141 # Set number value
142 if self.value_type == YAML.Input.TYPE_NUMBER:
143 if isinstance(value, bool):
144 raise ValueError(f'\'{value}\': Value is not a number')
145 if isinstance(value, (float, int)):
146 return value
147 if isinstance(value, str):
148 try:
149 if '.' in value:
150 return float(value)
151 return int(value)
152 except ValueError:
153 raise ValueError(f"'{value}': Value is not a number") # pylint: disable=raise-missing-from
155 # Set string value
156 if self.value_type == YAML.Input.TYPE_STRING:
157 if isinstance(value, (float, int, list)):
158 return str(value)
159 if isinstance(value, str):
160 return value
162 # Invalid value type
163 raise TypeError(f'\'{value}\': Value type \'{self.value_type}\' supported'
164 ) # pragma: no cover
166 # Reference class
167 class Reference:
169 # Constants
170 NESTED_LIMIT: int = 10
171 TAG: str = '!reference'
173 # Constructor
174 def __init__(self, values: List[str]):
175 self._values = values
177 # Values
178 @property
179 def values(self) -> List[str]:
180 return self._values
182 # Add constructor
183 @staticmethod
184 def add_constructor(
185 loader: yaml_SafeLoader,
186 node: yaml_SequenceNode,
187 ) -> 'YAML.Reference':
188 return YAML.Reference(loader.construct_sequence(node))
190 # Resolve, pylint: disable=too-many-branches
191 @staticmethod
192 def resolve(data: Any, node: Any) -> bool:
194 # Variables
195 changed: bool = False
197 # Dictionnaries
198 if isinstance(node, dict):
199 for key in node.keys():
200 if isinstance(node[key], YAML.Reference):
201 references = list(node[key].values)
202 referenced_node = data
203 for reference in references:
204 if reference in referenced_node:
205 referenced_node = referenced_node[reference]
206 else: # pragma: no cover
207 referenced_node = None
208 break
209 changed = True
210 if referenced_node:
211 node[key] = deepcopy(referenced_node)
212 else: # pragma: no cover
213 del node[key]
214 elif YAML.Reference.resolve(data, node[key]):
215 changed = True
217 # Lists
218 elif isinstance(node, list):
219 for index in reversed(range(len(node))):
220 if isinstance(node[index], YAML.Reference):
221 references = list(node[index].values)
222 referenced_node = data
223 for reference in references:
224 if reference in referenced_node:
225 referenced_node = referenced_node[reference]
226 else: # pragma: no cover
227 referenced_node = None
228 break
229 changed = True
230 if referenced_node:
231 node[index:index + 1] = deepcopy(referenced_node)
232 else: # pragma: no cover
233 del node[index]
234 elif YAML.Reference.resolve(data, node[index]):
235 changed = True # pragma: no cover
237 # Standard types
238 # else:
239 # pass
241 # Result
242 return changed
244 # Parse spec inputs
245 @staticmethod
246 def parse_spec_inputs(
247 spec: Data,
248 file_path: str,
249 file_inputs: Dict[str, str],
250 ) -> Dict[str, Input]:
252 # Variables
253 inputs: Dict[str, YAML.Input] = {}
255 # Validate YAML spec syntax
256 if 'spec' not in spec or 'inputs' not in spec['spec']:
257 return inputs
259 # Parse YAML spec inputs
260 for key, item in spec['spec']['inputs'].items():
262 # Prepare input
263 inputs[key] = YAML.Input()
264 if isinstance(item, dict):
265 if 'description' in item and isinstance(item['description'], str):
266 inputs[key].set_description(item['description'])
267 if 'type' in item and isinstance(item['type'], str):
268 inputs[key].set_type(item['type'])
269 if 'regex' in item and isinstance(item['regex'], str):
270 inputs[key].set_regex(item['regex'])
271 if 'options' in item and isinstance(item['options'], list):
272 inputs[key].set_options(list(item['options']))
273 if 'default' in item and isinstance(item['default'],
274 (bool, float, int, str)):
275 inputs[key].set_default(item['default'])
277 # Prepare input value
278 if isinstance(item,
279 dict) and key in file_inputs and file_inputs[key] is not None:
280 inputs[key].set_value(file_inputs[key])
281 elif inputs[key].default is not None:
282 inputs[key].set_value(inputs[key].default) # type: ignore[arg-type]
283 elif (options := inputs[key].options) is not None:
284 inputs[key].set_value(options[0])
286 # Validate input value
287 if inputs[key].value is None:
288 raise SyntaxError(f'Missing value for input \'{key}\' in \'{file_path}\'')
290 # Result
291 return inputs
293 # Evaluate string
294 @staticmethod
295 def evaluate_spec_field(field: str, inputs: Dict[str, Input]) -> str:
297 # Variables
298 result: str = field
300 # Evaluate inputs
301 for variable, node in inputs.items():
302 assert node.value is not None
303 result = regex_sub(fr'\$\[\[\s*inputs\.{variable}\s*\]\]', str(node.value),
304 result)
306 # Result
307 return result
309 # Evaluate spec
310 @staticmethod
311 def evaluate_spec(data: Data, inputs: Dict[str, Input]) -> Data:
313 # Recurse on keys
314 if isinstance(data, dict):
315 return {
316 YAML.evaluate_spec_field(key, inputs): YAML.evaluate_spec(value, inputs)
317 for key, value in data.items()
318 }
320 # Recurse on items
321 if isinstance(data, list):
322 return [YAML.evaluate_spec(value, inputs) for value in data]
324 # Evaluate string items
325 if isinstance(data, str):
326 return YAML.evaluate_spec_field(data, inputs)
328 # Fallback
329 return data # pragma: no cover
331 # Load, pylint: disable=too-many-branches
332 @staticmethod
333 def load(
334 stream: TextIOWrapper,
335 inputs: Dict[str, str],
336 configure: bool,
337 ) -> Optional[Data]:
339 # Variables
340 data: Optional[YAML.Data]
341 documents: List[YAML.Data]
342 file_inputs: Dict[str, YAML.Input]
344 # Prepare loader class
345 loader = yaml_SafeLoader
346 loader.add_constructor(YAML.Reference.TAG, YAML.Reference.add_constructor)
348 # Load YAML documents
349 documents = list(yaml_load_all(stream, Loader=loader))
351 # Empty YAML data
352 if not documents:
353 data = None
355 # Acquire YAML configuration data
356 elif len(documents) == 1:
357 data = documents[0]
359 # Acquire YAML component data
360 elif len(documents) == 2:
362 # Parse spec inputs
363 file_inputs = YAML.parse_spec_inputs(
364 documents[0],
365 stream.name,
366 file_inputs=inputs,
367 )
369 # Configure spec inputs
370 if configure:
371 from ..features.menus import MenusFeature # pylint: disable=cyclic-import,import-outside-toplevel
372 configure_inputs: Dict[str, Dict[str, bool | List[str] | str]] = {}
373 for key, node in file_inputs.items():
374 configure_inputs[key] = {}
375 configure_inputs[key]['help'] = node.description \
376 if node.description else 'Configure variable value'
377 if node.value_type == YAML.Input.TYPE_ARRAY: # pragma: no cover
378 raise TypeError(f'\'{key}\': Input type unknown value')
379 if node.value_type == YAML.Input.TYPE_BOOLEAN:
380 configure_inputs[key]['type'] = 'boolean'
381 elif node.options:
382 configure_inputs[key]['type'] = 'choice'
383 configure_inputs[key]['values'] = node.options
384 elif node.value_type == YAML.Input.TYPE_NUMBER:
385 configure_inputs[key]['type'] = 'number'
386 elif node.value_type == YAML.Input.TYPE_STRING:
387 configure_inputs[key]['type'] = 'input'
388 configure_inputs[key]['set'] = key in inputs
389 if key in inputs:
390 configure_inputs[key]['default'] = str(node.value)
391 elif str(node.default):
392 configure_inputs[key]['default'] = str(node.default)
393 else:
394 configure_inputs[key]['default'] = ''
395 configured_variables: Dict[str, str] = \
396 MenusFeature.configure(configure_inputs)
397 for key, value in configured_variables.items():
398 file_inputs[key].set_value(value)
400 # Evaluate spec inputs
401 data = YAML.evaluate_spec(
402 documents[1],
403 inputs=file_inputs,
404 )
406 # Unsupport YAML data
407 else:
408 raise SyntaxError(
409 f'Unsupported documents count ({len(documents)}) in \'{stream.name}\'')
411 # Result
412 return data
414 # Dump
415 @staticmethod
416 def dump(data: Data) -> str:
418 # Dumper, pylint: disable=too-many-ancestors
419 class Dumper(yaml_Dumper):
420 pass
422 # Representer
423 Dumper.add_representer(
424 str,
425 lambda dumper, data: dumper.represent_scalar(
426 'tag:yaml.org,2002:str',
427 data,
428 style='|' if '\n' in data else None,
429 ),
430 )
432 # Dump YAML data
433 return str(
434 yaml_dump(
435 data,
436 Dumper=Dumper,
437 indent=2,
438 sort_keys=False,
439 width=maxsize,
440 ))
442 # Resolve
443 @staticmethod
444 def resolve(data: Dict[Any, Any]) -> None:
446 # Resolve references
447 for _ in range(YAML.Reference.NESTED_LIMIT):
448 if not YAML.Reference.resolve(data, data):
449 break
450 else: # pragma: no cover
451 # error
452 pass