openrvdas.logger.utils.read_config
Utilities for reading/processing JSON data.
1#!/usr/bin/env python3 2"""Utilities for reading/processing JSON data. 3""" 4import copy 5import os 6import glob 7import logging 8import re 9import json 10from typing import Dict, List, Any, Union 11 12try: 13 import yaml 14except ModuleNotFoundError: 15 pass 16 17 18############################################################################### 19def read_config(file_path: str) -> Dict[str, Any]: 20 """ 21 Read a YAML configuration file. 22 23 Args: 24 file_path: Path to the YAML configuration file 25 26 Returns: 27 Dictionary containing the YAML content or empty dict on error 28 """ 29 try: 30 # Load the YAML file 31 with open(file_path, 'r') as file: 32 file_content = file.read() 33 return parse(file_content, file_path) 34 35 except FileNotFoundError: 36 logging.error(f'YAML file not found: "{file_path}"') 37 return {} 38 except Exception as e: 39 logging.error(f'Error reading file "{file_path}": {str(e)}') 40 return {} 41 42 43################### 44def parse(content: str, file_path: str = None) -> Dict[str, Any]: 45 """ 46 Parse YAML content and process includes. 47 48 Args: 49 content: The YAML content as a string 50 file_path: The original file path (for error reporting) 51 52 Returns: 53 Dictionary containing the merged YAML content or empty dict on error 54 """ 55 try: 56 # Parse the YAML content 57 try: 58 data = yaml.load(content, Loader=yaml.FullLoader) 59 except AttributeError: 60 # If they've got an older yaml, it may not have FullLoader) 61 data = yaml.load(content) 62 # Handle empty file 63 if data is None: 64 return {} 65 return data 66 67 except yaml.YAMLError as e: 68 logging.error(f'Invalid YAML syntax in "{file_path}": {str(e)}') 69 return {} 70 except Exception as e: 71 logging.error(f'Error parsing YAML in "{file_path}": {str(e)}') 72 return {} 73 74 75################### 76def expand_cruise_definition(input_dict): 77 """ 78 Expand a configuration dictionary with loggers and configs structure. 79 """ 80 # First handle includes to get all templates loaded 81 result = expand_includes(input_dict) 82 83 # Process both logger and config templates 84 result = expand_templates(result) 85 86 # Now do the global variable substitution 87 global_variables = result.get('variables', {}) 88 result = substitute_variables(result, global_variables) 89 90 # Continue with the rest of the process 91 result = expand_logger_definitions(result) 92 result = expand_modes(result) 93 94 unmatched_vars = find_unmatched_variables(result) 95 if unmatched_vars: 96 logging.error(f'Unexpanded variables found: ' 97 f'{", ".join(unmatched_vars)}') 98 99 return result 100 101 102################### 103def expand_wildcards(include_pattern: str, base_dir: str) -> List[str]: 104 """ 105 Expand a potentially wildcard-containing path to a list of matching files. 106 107 Args: 108 include_pattern: Pattern that may contain wildcards (e.g., "*.yaml") 109 base_dir: Base directory to resolve the pattern from 110 111 Returns: 112 List of matching file paths 113 """ 114 # Resolve relative paths 115 if not os.path.isabs(include_pattern): 116 full_pattern = os.path.normpath(os.path.join(base_dir, 117 include_pattern)) 118 else: 119 full_pattern = include_pattern 120 121 # Use glob to expand the pattern 122 matching_files = glob.glob(full_pattern) 123 124 if not matching_files: 125 logging.warning(f'No files found matching pattern: "{include_pattern}"') 126 127 return matching_files 128 129 130################### 131def expand_includes(input_dict: dict) -> Dict[str, Any]: 132 """ 133 Recursively process any included YAML files and merge them into the top 134 level. 135 136 Args: 137 input_dict (dict): The input dictionary optionally containing 138 'includes' and 'includes_base_dir' keys. 139 140 Returns: 141 Dictionary containing the merged YAML content or empty dict on error 142 143 Raises: 144 ValueError: If any included files are not found. 145 """ 146 147 # Default base_dir is top level project directory 148 base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 149 150 # If they've got an 'includes_base_dir' key in the file, use that. 151 includes_base_dir = input_dict.get('includes_base_dir') 152 if includes_base_dir is not None: 153 if not isinstance(includes_base_dir, str): 154 logging.error(f'Key "includes_base_dir" must be a dir path str; ' 155 f'found: {includes_base_dir}. Ignoring.') 156 else: 157 base_dir = includes_base_dir 158 159 # Handle includes if present 160 if 'includes' in input_dict and isinstance(input_dict['includes'], list): 161 included_data = {} 162 163 # Process each included file or pattern 164 for include_pattern in input_dict['includes']: 165 # Expand wildcards to get list of matching files 166 if isinstance(include_pattern, str): 167 include_pattern = include_pattern.strip() 168 matching_files = expand_wildcards(include_pattern, base_dir) 169 170 # Process each matching file 171 for include_path in matching_files: 172 # Use the directory of the include_path as the base_dir for 173 # nested includes 174 file_path = os.path.join(base_dir, include_path) 175 176 # Load the included file 177 included_content = read_config(file_path) 178 179 # Merge with current data 180 included_data = deep_merge(included_data, included_content) 181 182 # Remove the includes key before merging 183 includes_value = input_dict.pop('includes') 184 185 # Merge the original data on top of the included data 186 result = deep_merge(included_data, input_dict) 187 188 # Restore the includes key if needed 189 input_dict['includes'] = includes_value 190 191 return result 192 193 return input_dict 194 195 196def deep_merge(base: Dict[str, Any], 197 overlay: Dict[str, Any]) -> Dict[str, Any]: 198 """ 199 Deeply merge two dictionaries with special handling for different value 200 types: 201 - Scalar values: overwrite 202 - Lists: append 203 - Dictionaries: recursively merge 204 205 Args: 206 base: Base dictionary to merge into 207 overlay: Dictionary to merge on top of base 208 209 Returns: 210 Merged dictionary 211 """ 212 result = base.copy() 213 214 for key, value in overlay.items(): 215 if key in result: 216 # If both values are dictionaries, merge them recursively 217 if isinstance(result[key], dict) and isinstance(value, dict): 218 result[key] = deep_merge(result[key], value) 219 220 # If both values are lists, append them 221 elif isinstance(result[key], list) and isinstance(value, list): 222 result[key] = result[key] + value 223 224 # Otherwise overwrite (handles scalar case) 225 else: 226 result[key] = value 227 else: 228 # Key doesn't exist in result, just add it 229 result[key] = value 230 231 return result 232 233 234################### 235def expand_templates( 236 cruise_definition: Dict[str, Dict[str, Any]] 237) -> Dict[str, Dict[str, Any]]: 238 """ 239 Process a complete configuration dictionary with templates. 240 Handles both logger_templates and config_templates. 241 242 Args: 243 cruise_definition: Dictionary containing 'logger_templates', 244 'config_templates', 'loggers', and optionally 245 'variables' as top-level keys 246 247 Returns: 248 Dictionary with fully processed logger and config configurations 249 """ 250 # Extract components from the configuration dictionary 251 logger_templates = cruise_definition.get('logger_templates', {}) 252 config_templates = cruise_definition.get('config_templates', {}) 253 global_variables = cruise_definition.get('variables', {}) 254 255 # FIRST PHASE: Process logger templates 256 for logger_name, logger_def in cruise_definition.get('loggers', {}).items(): # noqa E501 257 if not isinstance(logger_def, dict): 258 raise ValueError(f'Malformed logger definition for {logger_name}; ' 259 f'should be dict.') 260 261 # Skip loggers that don't use logger_templates 262 template_name = logger_def.get('logger_template') 263 if not template_name: 264 continue 265 266 # Copy global variables so we can modify them 267 effective_variables = copy.deepcopy(global_variables) 268 effective_variables['logger'] = logger_name 269 270 # Override with logger-specific variables 271 logger_variables = logger_def.get('variables', {}) 272 effective_variables.update(logger_variables) 273 274 # Get the template 275 template = logger_templates.get(template_name) 276 if not template: 277 raise ValueError(f"Template '{template_name}' not found in " 278 f"logger_templates") 279 280 # Overlay the template on the existing logger definition, 281 # overwriting configs, etc. 282 merged_def = copy.deepcopy(template) 283 for key, value in logger_def.items(): 284 if key not in ['logger_template', 'variables']: 285 merged_def[key] = value 286 287 # Substitute variables 288 try: 289 processed_definition = substitute_variables(merged_def, 290 effective_variables) 291 except ValueError as e: 292 logging.error(f"Error processing logger '{logger_name}': {e}") 293 raise 294 295 # Clean up things that aren't needed 296 if 'variables' in processed_definition: 297 del processed_definition['variables'] 298 if 'logger_template' in processed_definition: 299 del processed_definition['logger_template'] 300 301 # Store the processed definition 302 cruise_definition['loggers'][logger_name] = processed_definition 303 304 # SECOND PHASE: Process config templates 305 for logger_name, logger_def in cruise_definition.get('loggers', {}).items(): # noqa E501 306 if not isinstance(logger_def, dict): 307 continue 308 309 # Process configs within this logger 310 if 'configs' not in logger_def or not isinstance(logger_def['configs'], dict): # noqa E501 311 continue 312 313 for config_name, config_def in logger_def['configs'].items(): 314 if not isinstance(config_def, dict): 315 continue 316 317 # Skip if no config_template is specified 318 if 'config_template' not in config_def: 319 continue 320 321 # Get template name and the template itself 322 template_name = config_def.get('config_template') 323 template = config_templates.get(template_name) 324 if not template: 325 raise ValueError(f"Template '{template_name}' not found in " 326 f"config_templates") 327 328 # Setup effective variables by merging global, logger, and config 329 # variables 330 effective_variables = copy.deepcopy(global_variables) 331 effective_variables['logger'] = logger_name 332 333 # Add logger-specific variables 334 if 'variables' in logger_def and isinstance(logger_def['variables'], dict): # noqa E501 335 effective_variables.update(logger_def['variables']) 336 337 # Add config-specific variables 338 if 'variables' in config_def and isinstance(config_def['variables'], dict): # noqa E501 339 effective_variables.update(config_def['variables']) 340 341 # Create a new config by copying the template 342 merged_config = copy.deepcopy(template) 343 344 # Check for missing variables before substitution and use global 345 # values 346 unmatched = find_unmatched_variables(merged_config) 347 for var in unmatched: 348 # Extract the variable name from the <<var>> format 349 var_name = var.strip('<>') 350 # If it's missing from the effective variables but exists 351 # in globals, use the global value 352 if var_name not in effective_variables and var_name in global_variables: # noqa E501 353 logging.info( 354 f"Using global value for '{var_name}' in config " 355 f"'{config_name}' for logger '{logger_name}'") 356 effective_variables[var_name] = global_variables[var_name] 357 358 # Apply variable substitution to the config 359 try: 360 processed_config = substitute_variables(merged_config, 361 effective_variables) 362 except ValueError as e: 363 missing_var = str(e).split("'")[1] if "Variable '" in str(e) else "unknown" # noqa E501 364 logging.error(f"Missing variable '{missing_var}' " 365 f"in config '{config_name}' for " 366 f"logger '{logger_name}'") 367 logging.error(f"Available variables: " 368 f"{', '.join(sorted(effective_variables.keys()))}") # noqa E501 369 raise 370 371 # Merge any extra non-template keys from the original config 372 for key, value in config_def.items(): 373 if key not in ['config_template', 'variables']: 374 processed_config[key] = value 375 376 # Update the config definition with the processed config 377 logger_def['configs'][config_name] = processed_config 378 379 # Clean up template definitions 380 if 'variables' in cruise_definition: 381 del cruise_definition['variables'] 382 if 'logger_templates' in cruise_definition: 383 del cruise_definition['logger_templates'] 384 if 'config_templates' in cruise_definition: 385 del cruise_definition['config_templates'] 386 387 # Apply global variables substitution 388 cruise_definition = substitute_variables(cruise_definition, 389 global_variables) 390 391 return cruise_definition 392 393 394# Define recursive ConfigValue type 395ConfigValue = Union[Dict[str, Any], List[Any], str, int, float, bool, None] 396 397 398def substitute_variables(config: ConfigValue, 399 variables: Dict[str, Any]) -> ConfigValue: 400 """ 401 Recursively substitute template variables in a configuration dictionary. 402 403 Args: 404 config: Dictionary or list containing template variables 405 variables: Dictionary of variable names and their values 406 407 Returns: 408 Configuration with all variables substituted 409 410 Recursively substitute template variables in a configuration structure. 411 412 Supports: 413 - <<var>> → replaces with variable value 414 - <<var|default>> → uses default if var missing 415 - nested defaults → <<var|<<fallback|default>>>> 416 - type conversion → <<timeout|10>> → int(10) 417 - pass-through unresolved placeholders 418 """ 419 420 def _convert_type(value: str) -> Any: 421 """ 422 Convert a string default value to a native Python type when possible. 423 """ 424 value = value.strip() 425 426 # Try to parse JSON literals (true/false/null/numbers) 427 try: 428 return json.loads(value) 429 except json.JSONDecodeError: 430 pass 431 432 try: 433 return int(value) 434 except ValueError: 435 try: 436 return float(value) 437 except ValueError: 438 return value 439 440 def _split_placeholder(expr: str): 441 """ 442 Split a placeholder expression into its variable name and optional 443 default. 444 445 Example expr values: 446 "var" 447 "var|default" 448 "var|<<fallback|default>>" 449 450 Returns a tuple containing the variable name and default value (or 451 None): 452 (var_name, default_expr_or_None) 453 454 The split on the '|' character only occurs at the top level (depth == 0). 455 Any '|' characters inside nested placeholders delimited by '<<' and '>>' 456 are ignored. Nesting depth is tracked by counting occurrences of '<<' and 457 '>>' while scanning the string from left to right. 458 459 Examples: 460 _split_placeholder("timeout|10") 461 -> ("timeout", "10") 462 463 _split_placeholder("a|<<b|c>>") 464 -> ("a", "<<b|c>>") 465 466 _split_placeholder("a") 467 -> ("a", None) 468 """ 469 depth, i = 0, 0 470 while i < len(expr): 471 if expr[i:i+2] == '<<': 472 depth += 1 473 i += 2 474 continue 475 if expr[i:i+2] == '>>': 476 depth -= 1 477 i += 2 478 continue 479 if expr[i] == '|' and depth == 0: 480 return expr[:i], expr[i+1:] 481 i += 1 482 483 return expr, None 484 485 def _resolve_variable(expr: str): 486 """ 487 Resolve a single placeholder expression to its final value. 488 489 Given the inner contents of a placeholder (the text between << and >>), 490 this function: 491 492 1. Splits the expression into a variable name and optional default 493 using `_split_placeholder`, supporting nested defaults. 494 2. If the variable name exists in `variables`, returns its value. 495 3. If the variable does not exists and a default expression is 496 present, recursively resolves the default expr via 497 `substitute_variables`, allowing chains such as: <<a|<<b|10>>>> 498 4. Applies type conversion to string defaults 499 (e.g. "10" → 10, "true" → True). 500 5. If variable does not exist and no a default can be resolved, 501 returns the original placeholder in pass-through form 502 (e.g. "<<a>>"). 503 504 Args: 505 expr: The inner contents of a placeholder (without the outer << >>). 506 507 Returns: 508 The resolved value in its native Python type (int, float, bool, 509 None, str, etc.) or the original placeholder string. 510 """ 511 512 name, default_expr = _split_placeholder(expr) 513 if name in variables: 514 return variables[name] 515 516 if default_expr is not None: 517 resolved = substitute_variables(default_expr, variables) 518 return _convert_type(resolved) if isinstance(resolved, str) else resolved 519 520 return f"<<{name}>>" 521 522 def _walk_string(expr: str) -> str: 523 """ 524 Scan the expression and replace variable syntax with actual variable 525 values. 526 527 For each complete variable syntax found, the inner expression is 528 resolved via `_resolve_variable`, and the resolved value is inserted 529 into the output. 530 531 Args: 532 expr: Input string possibly containing one or more <<...>> placeholders. 533 534 Returns: 535 A new string with all placeholders expanded and substituted. 536 """ 537 out, i = [], 0 538 while i < len(expr): 539 if expr[i:i+2] == '<<': 540 depth, j = 1, i + 2 541 while j < len(expr) and depth: 542 if expr[j:j+2] == '<<': 543 depth += 1 544 j += 2 545 elif expr[j:j+2] == '>>': 546 depth -= 1 547 j += 2 548 else: 549 j += 1 550 if depth != 0: # verify there was a closing >> 551 raise ValueError(f"Malformed variable syntax '{expr[i:]}'") 552 out.append(str(_resolve_variable(expr[i+2:j-2]))) 553 i = j 554 else: 555 out.append(expr[i]) 556 i += 1 557 return ''.join(out) 558 559 if isinstance(config, dict): 560 return {substitute_variables(k, variables): substitute_variables(v, variables) 561 for k, v in config.items()} 562 563 if isinstance(config, list): 564 return [substitute_variables(v, variables) for v in config] 565 566 if isinstance(config, str): 567 # Check whether the entire string is a single top-level placeholder. 568 # We track nesting depth so that a string like 569 # "<<file_root>>/<<logger>>/raw/<<cruise>>_<<logger>>" is NOT treated 570 # as a single placeholder (the first ">>" closes at position 12, not 571 # at the end of the string). 572 if config.startswith("<<"): 573 depth, i = 1, 2 574 while i < len(config) and depth: 575 if config[i:i+2] == '<<': 576 depth += 1 577 i += 2 578 elif config[i:i+2] == '>>': 579 depth -= 1 580 i += 2 581 else: 582 i += 1 583 if depth == 0 and i == len(config): 584 # The whole string is one placeholder — resolve with type 585 # preservation (so e.g. <<baud_rate>> can return an int). 586 return _resolve_variable(config[2:i-2]) 587 return _walk_string(config) 588 589 return config 590 591 592################### 593def expand_logger_definitions(input_dict): 594 """ 595 Expand a configuration dictionary with loggers and configs structure. 596 597 This function processes a dictionary with a 'loggers' key (required) and 598 an optional 'configs' key. It extracts config dictionaries from each logger 599 and moves them to the top level 'configs' section, replacing them with a 600 list of references. 601 602 Args: 603 input_dict (dict): The input dictionary containing 'loggers' and 604 optionally 'configs' keys. 605 606 Returns: 607 dict: A new dictionary with expanded configuration structure. 608 609 Raises: 610 ValueError: If the 'loggers' key is missing or if referenced configs 611 are missing. 612 613 ###This code is to support flexibility in defining cruise configurations.### 614 615 In the past, the "loggers" section of a cruise definition only allowed 616 declaring the names of each configuration associated with a logger. The 617 actual definition of each configuration had to be placed in a following 618 top-level "configs" section. 619 620 For example: 621 622 loggers: 623 PCOD: 624 configs: 625 - PCOD-off 626 - PCOD-net 627 - PCOD-net+file 628 cwnc: 629 ... 630 631 configs: 632 PCOD-off: {} 633 PCOD-net: 634 readers: 635 key1: value1 636 writers: 637 key2: value2 638 PCOD-net+file: 639 readers: 640 key1: value1 641 writers: 642 key2: value2 643 644 The old declaration-followed-by-definition method still works, but now, if 645 desired, the relevant configs may instead be defined within the logger 646 definition itself. 647 648 For example: 649 650 loggers: 651 PCOD: 652 configs: 653 'off': {} 654 net: 655 readers: 656 key1: value1 657 writers: 658 key2: value2 659 net+file: 660 readers: 661 key1: value1 662 writers: 663 key2: value2 664 665 In this case, the config names will have the logger name prepended 666 (e.g. 'off' becomes PCOD-off, net becomes PCOD-net, etc.) 667 668 Note that both methods may be used in a single cruise definition, though 669 for clarity, this is not advised. 670 """ 671 # Validate input 672 if 'loggers' not in input_dict: 673 raise ValueError("Input dictionary must have a 'loggers' key") 674 675 # Create a new dictionary to avoid modifying the input 676 result = copy.deepcopy(input_dict) 677 678 # Ensure configs key exists in the result 679 if 'configs' not in result: 680 result['configs'] = {} 681 682 # Process each logger 683 for logger_name, logger_data in input_dict['loggers'].items(): 684 # Skip if no configs key in this logger 685 if 'configs' not in logger_data: 686 continue 687 688 # Get the configs for this logger 689 logger_configs = logger_data['configs'] 690 691 # Handle the case where configs is a list of strings 692 if isinstance(logger_configs, list): 693 # Verify each referenced config exists in top-level configs 694 for config_name in logger_configs: 695 if config_name not in result['configs']: 696 raise ValueError(f"Referenced config '{config_name}' " 697 "not found in top-level configs") 698 699 # Handle the case where configs is a dictionary of dictionaries 700 elif isinstance(logger_configs, dict): 701 # Create a new config list for this logger 702 new_config_list = [] 703 704 # Process each config in this logger 705 for config_key, config_value in logger_configs.items(): 706 # Generate the new config name 707 config_name = f"{logger_name}-{config_key}" 708 709 # Add to the config list 710 new_config_list.append(config_name) 711 712 # Check for potential overwrites in the top-level configs 713 if config_name in result['configs']: 714 print(f"Warning: Overwriting existing config " 715 f"'{config_name}' in top-level configs") 716 717 # Add the config to the top-level configs 718 result['configs'][config_name] = config_value 719 720 # Replace the logger's configs dict with the list of config names 721 result['loggers'][logger_name]['configs'] = new_config_list 722 723 return result 724 725 726################### 727def expand_modes(input_dict): 728 """ 729 Expand or infer the modes section of a cruise definition dict. 730 731 This function processes a dictionary with a 'loggers' key (required) and 732 an optional 'configs' key. It extracts config dictionaries from each 733 logger and moves them to the top level 'configs' section, replacing them 734 with a list of references. 735 736 Args: 737 input_dict (dict): The input dictionary (possibly) containing 'modes' 738 and 'configs' keys. 739 740 Returns: 741 dict: A new dictionary with expanded configuration structure. 742 743 Raises: 744 ValueError: If the 'configs' key is missing or if referenced configs 745 are missing. 746 747 ### This code is to support flexibility in defining cruise configurations. 748 749 In the past, cruise modes were required to be dicts mapping a logger name 750 to config. We can infer that dict from a simple list of configs. 751 """ 752 # Validate input 753 if 'loggers' not in input_dict: 754 raise ValueError("Cruise definition missing loggers?!?") 755 if 'configs' not in input_dict: 756 raise ValueError("Cruise definition missing configs?!?") 757 758 # No modes defined (or an empty 'modes' declaration)? Create a default one 759 modes = input_dict.get('modes') 760 if not modes: 761 logging.warning('No "modes" section found. Generating default mode.') 762 return generate_default_mode(input_dict) 763 764 # 'modes' is there. Is it a dict? 765 if not isinstance(modes, dict): 766 raise ValueError(f"'modes' definition must be a dict of modes. " 767 f"Found {type(modes)}") 768 769 # This is the copy we're going to modify and return 770 result = copy.deepcopy(input_dict) 771 772 loggers = input_dict.get('loggers') 773 for mode_name, mode_configs in input_dict.get('modes').items(): 774 # Mode is already in expanded form - nothing to do 775 if isinstance(mode_configs, dict): 776 continue 777 778 if not isinstance(mode_configs, list): 779 raise ValueError(f"Mode {mode_name} must be either dict " 780 f"or list; found {type(mode_configs)}") 781 782 # If here, we've got a list of configs that should be run in this mode. 783 # Figure out which loggers they belong to and expand into the normal 784 # dict form. 785 mode_dict = {} 786 for config_name in mode_configs: 787 # Look through loggers for this config_name 788 found = False 789 for logger_name, logger_def in loggers.items(): 790 logger_configs = logger_def.get('configs') 791 if not logger_configs: 792 raise ValueError(f"Logger {logger_name} has no configs!") 793 if config_name in logger_configs: 794 mode_dict[logger_name] = config_name 795 found = True 796 break 797 if not found: 798 raise ValueError(f"No logger found for {config_name} in " 799 f"mode {mode_name}") 800 801 # Now confirm that each logger has had a config defined 802 for logger_name in loggers: 803 if logger_name not in mode_dict: 804 raise ValueError(f"No config defined for {logger_name} " 805 f"in mode {mode_name}") 806 807 # Replace the config list with newly-created config dict 808 result['modes'][mode_name] = mode_dict 809 810 # Is there a default mode defined? If not, pick the first one in the 811 # dict and define it as the default. 812 if 'default_mode' not in result: 813 first_mode = next(iter(result.get('modes'))) 814 result['default_mode'] = first_mode 815 816 return result 817 818 819################### 820def generate_default_mode(input_dict): 821 """ 822 If no 'modes' section is present in input_dict, create one that has a 823 single mode, named 'default', using the first config defined for each 824 logger. 825 826 Args: 827 input_dict (dict): The input dictionary containing 'loggers' and 828 optionally 'configs' keys. 829 830 Returns: 831 dict: A new dictionary with modes and default_mode keys. 832 833 Raises: 834 ValueError: If the 'loggers' key is missing or if referenced configs 835 are missing. 836 """ 837 838 # Now it's time to check up on modes - do we actually have a modes key? 839 if 'modes' in input_dict: 840 return input_dict 841 842 # If not, create one. 843 # Create a new dictionary to avoid modifying the input 844 result = copy.deepcopy(input_dict) 845 default_mode = {} 846 847 for logger_name, logger_data in input_dict['loggers'].items(): 848 # Skip if no configs key in this logger 849 if 'configs' not in logger_data: 850 raise ValueError(f"Logger {logger_name} has no configs") 851 # Get the configs for this logger 852 logger_configs = logger_data['configs'] 853 854 # Handle the case where configs is a list of strings 855 if not isinstance(logger_configs, list) or not len(logger_configs): 856 raise ValueError(f"Logger {logger_name} config list is not a " 857 f"list? Found type {type(logger_configs)}: " 858 f"{logger_configs}") 859 default_mode[logger_name] = logger_configs[0] 860 result['modes'] = {'default': default_mode} 861 result['default_mode'] = 'default' 862 863 return result 864 865 866############################################################################## 867def find_unmatched_variables(data: Union[Dict, List, str, Any]) -> List[str]: 868 """ 869 Recursively searches through a nested data structure (dicts, lists, 870 strings) and finds all variables that begin with "<<" and end with ">>", 871 returning them with the brackets intact. These typically represent template 872 variables. 873 874 Args: 875 data: A dict, list, string, or other value to search through 876 877 Returns: 878 List of extracted strings with "<<" and ">>" included 879 """ 880 results = [] 881 882 if isinstance(data, dict): 883 # Search through dictionary keys and values 884 for key, value in data.items(): 885 # Check if key is a string that might contain bracketed strings 886 if isinstance(key, str): 887 results.extend(_extract_from_string(key)) 888 889 # Recursively check the value 890 results.extend(find_unmatched_variables(value)) 891 892 elif isinstance(data, list): 893 # Search through list elements 894 for item in data: 895 results.extend(find_unmatched_variables(item)) 896 897 elif isinstance(data, str): 898 # Search within the string 899 results.extend(_extract_from_string(data)) 900 901 # Return unique results (no duplicates) 902 return list(set(results)) 903 904 905def _extract_from_string(text: str) -> List[str]: 906 """ 907 Helper function to extract all "<<...>>" patterns from a string 908 909 Args: 910 text: String to search within 911 912 Returns: 913 List of extracted strings with the brackets included 914 """ 915 pattern = r"(<<[^<]*>>)" 916 matches = re.findall(pattern, text) 917 return matches 918 919 920############################################################################## 921def load_definitions(definition_path): 922 """ 923 Load and merge device definitions from YAML files. 924 925 This is a shared utility used by RegexParser and RecordParser to load 926 device and device_type definitions from YAML configuration files. 927 928 Supports both the new structured format and the legacy format: 929 930 New format: 931 devices: 932 device_name: 933 device_type: SomeType 934 device_types: 935 SomeType: 936 format: ... 937 938 Legacy format (deprecated): 939 device_name: 940 category: device 941 device_type: SomeType 942 SomeType: 943 category: device_type 944 format: ... 945 946 Args: 947 definition_path: Comma-separated glob patterns for definition files. 948 Example: 'local/devices/*.yaml,contrib/devices/*.yaml' 949 950 Returns: 951 Dict with structure: 952 { 953 'devices': {device_name: device_def, ...}, 954 'device_types': {type_name: type_def, ...} 955 } 956 957 Returns empty structure if definition_path is None or no files found. 958 """ 959 definitions = {'devices': {}, 'device_types': {}} 960 961 if not definition_path: 962 return definitions 963 964 def_files = [] 965 for path_glob in definition_path.split(','): 966 matched = glob.glob(path_glob.strip()) 967 if not matched: 968 logging.debug('No files match definition path "%s"', path_glob.strip()) 969 def_files.extend(matched) 970 971 if not def_files: 972 return definitions 973 974 for filename in def_files: 975 file_defs = read_config(filename) 976 file_defs = expand_includes(file_defs) 977 978 for key, val in file_defs.items(): 979 # New format: 'devices' key contains dict of device definitions 980 if key == 'devices': 981 if not isinstance(val, dict): 982 logging.error('"devices" value in file %s must be dict. ' 983 'Found type "%s"', filename, type(val)) 984 continue 985 for name, defn in val.items(): 986 if name in definitions['devices']: 987 logging.warning('Duplicate device definition "%s" in %s', 988 name, filename) 989 definitions['devices'][name] = defn 990 991 # New format: 'device_types' key contains dict of device type definitions 992 elif key == 'device_types': 993 if not isinstance(val, dict): 994 logging.error('"device_types" value in file %s must be dict. ' 995 'Found type "%s"', filename, type(val)) 996 continue 997 for name, defn in val.items(): 998 if name in definitions['device_types']: 999 logging.warning('Duplicate device_type definition "%s" in %s', 1000 name, filename) 1001 definitions['device_types'][name] = defn 1002 1003 # Skip 'includes' - already handled by expand_includes 1004 elif key == 'includes': 1005 pass 1006 1007 # Legacy format: top-level key with 'category' field 1008 elif isinstance(val, dict) and 'category' in val: 1009 category = val.get('category') 1010 if category == 'device': 1011 if key in definitions['devices']: 1012 logging.warning('Duplicate device definition "%s" in %s', 1013 key, filename) 1014 definitions['devices'][key] = val 1015 elif category == 'device_type': 1016 if key in definitions['device_types']: 1017 logging.warning('Duplicate device_type definition "%s" in %s', 1018 key, filename) 1019 definitions['device_types'][key] = val 1020 else: 1021 logging.warning('Top-level definition "%s" in file %s has ' 1022 'unrecognized category "%s" - ignoring', 1023 key, filename, category) 1024 1025 # Unknown top-level key 1026 else: 1027 logging.debug('Ignoring unknown top-level key "%s" in %s', key, filename) 1028 1029 return definitions 1030 1031 1032############################################################################## 1033############################################################################## 1034if __name__ == "__main__": 1035 import argparse 1036 1037 parser = argparse.ArgumentParser() 1038 parser.add_argument('-v', '--verbosity', dest='verbosity', default=0, 1039 action='count', help='Increase output verbosity') 1040 parser.add_argument('filename', type=str, help='Input file to process') 1041 args = parser.parse_args() 1042 1043 LOG_LEVELS = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} 1044 args.verbosity = min(args.verbosity, max(LOG_LEVELS)) 1045 logging.getLogger().setLevel(LOG_LEVELS[args.verbosity]) 1046 1047 config = read_config(args.filename) 1048 config = expand_cruise_definition(config) 1049 1050 print(yaml.dump(config, sort_keys=False))
20def read_config(file_path: str) -> Dict[str, Any]: 21 """ 22 Read a YAML configuration file. 23 24 Args: 25 file_path: Path to the YAML configuration file 26 27 Returns: 28 Dictionary containing the YAML content or empty dict on error 29 """ 30 try: 31 # Load the YAML file 32 with open(file_path, 'r') as file: 33 file_content = file.read() 34 return parse(file_content, file_path) 35 36 except FileNotFoundError: 37 logging.error(f'YAML file not found: "{file_path}"') 38 return {} 39 except Exception as e: 40 logging.error(f'Error reading file "{file_path}": {str(e)}') 41 return {}
Read a YAML configuration file.
Args: file_path: Path to the YAML configuration file
Returns: Dictionary containing the YAML content or empty dict on error
45def parse(content: str, file_path: str = None) -> Dict[str, Any]: 46 """ 47 Parse YAML content and process includes. 48 49 Args: 50 content: The YAML content as a string 51 file_path: The original file path (for error reporting) 52 53 Returns: 54 Dictionary containing the merged YAML content or empty dict on error 55 """ 56 try: 57 # Parse the YAML content 58 try: 59 data = yaml.load(content, Loader=yaml.FullLoader) 60 except AttributeError: 61 # If they've got an older yaml, it may not have FullLoader) 62 data = yaml.load(content) 63 # Handle empty file 64 if data is None: 65 return {} 66 return data 67 68 except yaml.YAMLError as e: 69 logging.error(f'Invalid YAML syntax in "{file_path}": {str(e)}') 70 return {} 71 except Exception as e: 72 logging.error(f'Error parsing YAML in "{file_path}": {str(e)}') 73 return {}
Parse YAML content and process includes.
Args: content: The YAML content as a string file_path: The original file path (for error reporting)
Returns: Dictionary containing the merged YAML content or empty dict on error
77def expand_cruise_definition(input_dict): 78 """ 79 Expand a configuration dictionary with loggers and configs structure. 80 """ 81 # First handle includes to get all templates loaded 82 result = expand_includes(input_dict) 83 84 # Process both logger and config templates 85 result = expand_templates(result) 86 87 # Now do the global variable substitution 88 global_variables = result.get('variables', {}) 89 result = substitute_variables(result, global_variables) 90 91 # Continue with the rest of the process 92 result = expand_logger_definitions(result) 93 result = expand_modes(result) 94 95 unmatched_vars = find_unmatched_variables(result) 96 if unmatched_vars: 97 logging.error(f'Unexpanded variables found: ' 98 f'{", ".join(unmatched_vars)}') 99 100 return result
Expand a configuration dictionary with loggers and configs structure.
104def expand_wildcards(include_pattern: str, base_dir: str) -> List[str]: 105 """ 106 Expand a potentially wildcard-containing path to a list of matching files. 107 108 Args: 109 include_pattern: Pattern that may contain wildcards (e.g., "*.yaml") 110 base_dir: Base directory to resolve the pattern from 111 112 Returns: 113 List of matching file paths 114 """ 115 # Resolve relative paths 116 if not os.path.isabs(include_pattern): 117 full_pattern = os.path.normpath(os.path.join(base_dir, 118 include_pattern)) 119 else: 120 full_pattern = include_pattern 121 122 # Use glob to expand the pattern 123 matching_files = glob.glob(full_pattern) 124 125 if not matching_files: 126 logging.warning(f'No files found matching pattern: "{include_pattern}"') 127 128 return matching_files
Expand a potentially wildcard-containing path to a list of matching files.
Args: include_pattern: Pattern that may contain wildcards (e.g., "*.yaml") base_dir: Base directory to resolve the pattern from
Returns: List of matching file paths
132def expand_includes(input_dict: dict) -> Dict[str, Any]: 133 """ 134 Recursively process any included YAML files and merge them into the top 135 level. 136 137 Args: 138 input_dict (dict): The input dictionary optionally containing 139 'includes' and 'includes_base_dir' keys. 140 141 Returns: 142 Dictionary containing the merged YAML content or empty dict on error 143 144 Raises: 145 ValueError: If any included files are not found. 146 """ 147 148 # Default base_dir is top level project directory 149 base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 150 151 # If they've got an 'includes_base_dir' key in the file, use that. 152 includes_base_dir = input_dict.get('includes_base_dir') 153 if includes_base_dir is not None: 154 if not isinstance(includes_base_dir, str): 155 logging.error(f'Key "includes_base_dir" must be a dir path str; ' 156 f'found: {includes_base_dir}. Ignoring.') 157 else: 158 base_dir = includes_base_dir 159 160 # Handle includes if present 161 if 'includes' in input_dict and isinstance(input_dict['includes'], list): 162 included_data = {} 163 164 # Process each included file or pattern 165 for include_pattern in input_dict['includes']: 166 # Expand wildcards to get list of matching files 167 if isinstance(include_pattern, str): 168 include_pattern = include_pattern.strip() 169 matching_files = expand_wildcards(include_pattern, base_dir) 170 171 # Process each matching file 172 for include_path in matching_files: 173 # Use the directory of the include_path as the base_dir for 174 # nested includes 175 file_path = os.path.join(base_dir, include_path) 176 177 # Load the included file 178 included_content = read_config(file_path) 179 180 # Merge with current data 181 included_data = deep_merge(included_data, included_content) 182 183 # Remove the includes key before merging 184 includes_value = input_dict.pop('includes') 185 186 # Merge the original data on top of the included data 187 result = deep_merge(included_data, input_dict) 188 189 # Restore the includes key if needed 190 input_dict['includes'] = includes_value 191 192 return result 193 194 return input_dict
Recursively process any included YAML files and merge them into the top level.
Args: input_dict (dict): The input dictionary optionally containing 'includes' and 'includes_base_dir' keys.
Returns: Dictionary containing the merged YAML content or empty dict on error
Raises: ValueError: If any included files are not found.
197def deep_merge(base: Dict[str, Any], 198 overlay: Dict[str, Any]) -> Dict[str, Any]: 199 """ 200 Deeply merge two dictionaries with special handling for different value 201 types: 202 - Scalar values: overwrite 203 - Lists: append 204 - Dictionaries: recursively merge 205 206 Args: 207 base: Base dictionary to merge into 208 overlay: Dictionary to merge on top of base 209 210 Returns: 211 Merged dictionary 212 """ 213 result = base.copy() 214 215 for key, value in overlay.items(): 216 if key in result: 217 # If both values are dictionaries, merge them recursively 218 if isinstance(result[key], dict) and isinstance(value, dict): 219 result[key] = deep_merge(result[key], value) 220 221 # If both values are lists, append them 222 elif isinstance(result[key], list) and isinstance(value, list): 223 result[key] = result[key] + value 224 225 # Otherwise overwrite (handles scalar case) 226 else: 227 result[key] = value 228 else: 229 # Key doesn't exist in result, just add it 230 result[key] = value 231 232 return result
Deeply merge two dictionaries with special handling for different value types:
- Scalar values: overwrite
- Lists: append
- Dictionaries: recursively merge
Args: base: Base dictionary to merge into overlay: Dictionary to merge on top of base
Returns: Merged dictionary
236def expand_templates( 237 cruise_definition: Dict[str, Dict[str, Any]] 238) -> Dict[str, Dict[str, Any]]: 239 """ 240 Process a complete configuration dictionary with templates. 241 Handles both logger_templates and config_templates. 242 243 Args: 244 cruise_definition: Dictionary containing 'logger_templates', 245 'config_templates', 'loggers', and optionally 246 'variables' as top-level keys 247 248 Returns: 249 Dictionary with fully processed logger and config configurations 250 """ 251 # Extract components from the configuration dictionary 252 logger_templates = cruise_definition.get('logger_templates', {}) 253 config_templates = cruise_definition.get('config_templates', {}) 254 global_variables = cruise_definition.get('variables', {}) 255 256 # FIRST PHASE: Process logger templates 257 for logger_name, logger_def in cruise_definition.get('loggers', {}).items(): # noqa E501 258 if not isinstance(logger_def, dict): 259 raise ValueError(f'Malformed logger definition for {logger_name}; ' 260 f'should be dict.') 261 262 # Skip loggers that don't use logger_templates 263 template_name = logger_def.get('logger_template') 264 if not template_name: 265 continue 266 267 # Copy global variables so we can modify them 268 effective_variables = copy.deepcopy(global_variables) 269 effective_variables['logger'] = logger_name 270 271 # Override with logger-specific variables 272 logger_variables = logger_def.get('variables', {}) 273 effective_variables.update(logger_variables) 274 275 # Get the template 276 template = logger_templates.get(template_name) 277 if not template: 278 raise ValueError(f"Template '{template_name}' not found in " 279 f"logger_templates") 280 281 # Overlay the template on the existing logger definition, 282 # overwriting configs, etc. 283 merged_def = copy.deepcopy(template) 284 for key, value in logger_def.items(): 285 if key not in ['logger_template', 'variables']: 286 merged_def[key] = value 287 288 # Substitute variables 289 try: 290 processed_definition = substitute_variables(merged_def, 291 effective_variables) 292 except ValueError as e: 293 logging.error(f"Error processing logger '{logger_name}': {e}") 294 raise 295 296 # Clean up things that aren't needed 297 if 'variables' in processed_definition: 298 del processed_definition['variables'] 299 if 'logger_template' in processed_definition: 300 del processed_definition['logger_template'] 301 302 # Store the processed definition 303 cruise_definition['loggers'][logger_name] = processed_definition 304 305 # SECOND PHASE: Process config templates 306 for logger_name, logger_def in cruise_definition.get('loggers', {}).items(): # noqa E501 307 if not isinstance(logger_def, dict): 308 continue 309 310 # Process configs within this logger 311 if 'configs' not in logger_def or not isinstance(logger_def['configs'], dict): # noqa E501 312 continue 313 314 for config_name, config_def in logger_def['configs'].items(): 315 if not isinstance(config_def, dict): 316 continue 317 318 # Skip if no config_template is specified 319 if 'config_template' not in config_def: 320 continue 321 322 # Get template name and the template itself 323 template_name = config_def.get('config_template') 324 template = config_templates.get(template_name) 325 if not template: 326 raise ValueError(f"Template '{template_name}' not found in " 327 f"config_templates") 328 329 # Setup effective variables by merging global, logger, and config 330 # variables 331 effective_variables = copy.deepcopy(global_variables) 332 effective_variables['logger'] = logger_name 333 334 # Add logger-specific variables 335 if 'variables' in logger_def and isinstance(logger_def['variables'], dict): # noqa E501 336 effective_variables.update(logger_def['variables']) 337 338 # Add config-specific variables 339 if 'variables' in config_def and isinstance(config_def['variables'], dict): # noqa E501 340 effective_variables.update(config_def['variables']) 341 342 # Create a new config by copying the template 343 merged_config = copy.deepcopy(template) 344 345 # Check for missing variables before substitution and use global 346 # values 347 unmatched = find_unmatched_variables(merged_config) 348 for var in unmatched: 349 # Extract the variable name from the <<var>> format 350 var_name = var.strip('<>') 351 # If it's missing from the effective variables but exists 352 # in globals, use the global value 353 if var_name not in effective_variables and var_name in global_variables: # noqa E501 354 logging.info( 355 f"Using global value for '{var_name}' in config " 356 f"'{config_name}' for logger '{logger_name}'") 357 effective_variables[var_name] = global_variables[var_name] 358 359 # Apply variable substitution to the config 360 try: 361 processed_config = substitute_variables(merged_config, 362 effective_variables) 363 except ValueError as e: 364 missing_var = str(e).split("'")[1] if "Variable '" in str(e) else "unknown" # noqa E501 365 logging.error(f"Missing variable '{missing_var}' " 366 f"in config '{config_name}' for " 367 f"logger '{logger_name}'") 368 logging.error(f"Available variables: " 369 f"{', '.join(sorted(effective_variables.keys()))}") # noqa E501 370 raise 371 372 # Merge any extra non-template keys from the original config 373 for key, value in config_def.items(): 374 if key not in ['config_template', 'variables']: 375 processed_config[key] = value 376 377 # Update the config definition with the processed config 378 logger_def['configs'][config_name] = processed_config 379 380 # Clean up template definitions 381 if 'variables' in cruise_definition: 382 del cruise_definition['variables'] 383 if 'logger_templates' in cruise_definition: 384 del cruise_definition['logger_templates'] 385 if 'config_templates' in cruise_definition: 386 del cruise_definition['config_templates'] 387 388 # Apply global variables substitution 389 cruise_definition = substitute_variables(cruise_definition, 390 global_variables) 391 392 return cruise_definition
Process a complete configuration dictionary with templates. Handles both logger_templates and config_templates.
Args: cruise_definition: Dictionary containing 'logger_templates', 'config_templates', 'loggers', and optionally 'variables' as top-level keys
Returns: Dictionary with fully processed logger and config configurations
399def substitute_variables(config: ConfigValue, 400 variables: Dict[str, Any]) -> ConfigValue: 401 """ 402 Recursively substitute template variables in a configuration dictionary. 403 404 Args: 405 config: Dictionary or list containing template variables 406 variables: Dictionary of variable names and their values 407 408 Returns: 409 Configuration with all variables substituted 410 411 Recursively substitute template variables in a configuration structure. 412 413 Supports: 414 - <<var>> → replaces with variable value 415 - <<var|default>> → uses default if var missing 416 - nested defaults → <<var|<<fallback|default>>>> 417 - type conversion → <<timeout|10>> → int(10) 418 - pass-through unresolved placeholders 419 """ 420 421 def _convert_type(value: str) -> Any: 422 """ 423 Convert a string default value to a native Python type when possible. 424 """ 425 value = value.strip() 426 427 # Try to parse JSON literals (true/false/null/numbers) 428 try: 429 return json.loads(value) 430 except json.JSONDecodeError: 431 pass 432 433 try: 434 return int(value) 435 except ValueError: 436 try: 437 return float(value) 438 except ValueError: 439 return value 440 441 def _split_placeholder(expr: str): 442 """ 443 Split a placeholder expression into its variable name and optional 444 default. 445 446 Example expr values: 447 "var" 448 "var|default" 449 "var|<<fallback|default>>" 450 451 Returns a tuple containing the variable name and default value (or 452 None): 453 (var_name, default_expr_or_None) 454 455 The split on the '|' character only occurs at the top level (depth == 0). 456 Any '|' characters inside nested placeholders delimited by '<<' and '>>' 457 are ignored. Nesting depth is tracked by counting occurrences of '<<' and 458 '>>' while scanning the string from left to right. 459 460 Examples: 461 _split_placeholder("timeout|10") 462 -> ("timeout", "10") 463 464 _split_placeholder("a|<<b|c>>") 465 -> ("a", "<<b|c>>") 466 467 _split_placeholder("a") 468 -> ("a", None) 469 """ 470 depth, i = 0, 0 471 while i < len(expr): 472 if expr[i:i+2] == '<<': 473 depth += 1 474 i += 2 475 continue 476 if expr[i:i+2] == '>>': 477 depth -= 1 478 i += 2 479 continue 480 if expr[i] == '|' and depth == 0: 481 return expr[:i], expr[i+1:] 482 i += 1 483 484 return expr, None 485 486 def _resolve_variable(expr: str): 487 """ 488 Resolve a single placeholder expression to its final value. 489 490 Given the inner contents of a placeholder (the text between << and >>), 491 this function: 492 493 1. Splits the expression into a variable name and optional default 494 using `_split_placeholder`, supporting nested defaults. 495 2. If the variable name exists in `variables`, returns its value. 496 3. If the variable does not exists and a default expression is 497 present, recursively resolves the default expr via 498 `substitute_variables`, allowing chains such as: <<a|<<b|10>>>> 499 4. Applies type conversion to string defaults 500 (e.g. "10" → 10, "true" → True). 501 5. If variable does not exist and no a default can be resolved, 502 returns the original placeholder in pass-through form 503 (e.g. "<<a>>"). 504 505 Args: 506 expr: The inner contents of a placeholder (without the outer << >>). 507 508 Returns: 509 The resolved value in its native Python type (int, float, bool, 510 None, str, etc.) or the original placeholder string. 511 """ 512 513 name, default_expr = _split_placeholder(expr) 514 if name in variables: 515 return variables[name] 516 517 if default_expr is not None: 518 resolved = substitute_variables(default_expr, variables) 519 return _convert_type(resolved) if isinstance(resolved, str) else resolved 520 521 return f"<<{name}>>" 522 523 def _walk_string(expr: str) -> str: 524 """ 525 Scan the expression and replace variable syntax with actual variable 526 values. 527 528 For each complete variable syntax found, the inner expression is 529 resolved via `_resolve_variable`, and the resolved value is inserted 530 into the output. 531 532 Args: 533 expr: Input string possibly containing one or more <<...>> placeholders. 534 535 Returns: 536 A new string with all placeholders expanded and substituted. 537 """ 538 out, i = [], 0 539 while i < len(expr): 540 if expr[i:i+2] == '<<': 541 depth, j = 1, i + 2 542 while j < len(expr) and depth: 543 if expr[j:j+2] == '<<': 544 depth += 1 545 j += 2 546 elif expr[j:j+2] == '>>': 547 depth -= 1 548 j += 2 549 else: 550 j += 1 551 if depth != 0: # verify there was a closing >> 552 raise ValueError(f"Malformed variable syntax '{expr[i:]}'") 553 out.append(str(_resolve_variable(expr[i+2:j-2]))) 554 i = j 555 else: 556 out.append(expr[i]) 557 i += 1 558 return ''.join(out) 559 560 if isinstance(config, dict): 561 return {substitute_variables(k, variables): substitute_variables(v, variables) 562 for k, v in config.items()} 563 564 if isinstance(config, list): 565 return [substitute_variables(v, variables) for v in config] 566 567 if isinstance(config, str): 568 # Check whether the entire string is a single top-level placeholder. 569 # We track nesting depth so that a string like 570 # "<<file_root>>/<<logger>>/raw/<<cruise>>_<<logger>>" is NOT treated 571 # as a single placeholder (the first ">>" closes at position 12, not 572 # at the end of the string). 573 if config.startswith("<<"): 574 depth, i = 1, 2 575 while i < len(config) and depth: 576 if config[i:i+2] == '<<': 577 depth += 1 578 i += 2 579 elif config[i:i+2] == '>>': 580 depth -= 1 581 i += 2 582 else: 583 i += 1 584 if depth == 0 and i == len(config): 585 # The whole string is one placeholder — resolve with type 586 # preservation (so e.g. <<baud_rate>> can return an int). 587 return _resolve_variable(config[2:i-2]) 588 return _walk_string(config) 589 590 return config
Recursively substitute template variables in a configuration dictionary.
Args: config: Dictionary or list containing template variables variables: Dictionary of variable names and their values
Returns: Configuration with all variables substituted
Recursively substitute template variables in a configuration structure.
Supports:
- <> → replaces with variable value
- <> → uses default if var missing
- nested defaults → <>>>
- type conversion → <
> → int(10) - pass-through unresolved placeholders
594def expand_logger_definitions(input_dict): 595 """ 596 Expand a configuration dictionary with loggers and configs structure. 597 598 This function processes a dictionary with a 'loggers' key (required) and 599 an optional 'configs' key. It extracts config dictionaries from each logger 600 and moves them to the top level 'configs' section, replacing them with a 601 list of references. 602 603 Args: 604 input_dict (dict): The input dictionary containing 'loggers' and 605 optionally 'configs' keys. 606 607 Returns: 608 dict: A new dictionary with expanded configuration structure. 609 610 Raises: 611 ValueError: If the 'loggers' key is missing or if referenced configs 612 are missing. 613 614 ###This code is to support flexibility in defining cruise configurations.### 615 616 In the past, the "loggers" section of a cruise definition only allowed 617 declaring the names of each configuration associated with a logger. The 618 actual definition of each configuration had to be placed in a following 619 top-level "configs" section. 620 621 For example: 622 623 loggers: 624 PCOD: 625 configs: 626 - PCOD-off 627 - PCOD-net 628 - PCOD-net+file 629 cwnc: 630 ... 631 632 configs: 633 PCOD-off: {} 634 PCOD-net: 635 readers: 636 key1: value1 637 writers: 638 key2: value2 639 PCOD-net+file: 640 readers: 641 key1: value1 642 writers: 643 key2: value2 644 645 The old declaration-followed-by-definition method still works, but now, if 646 desired, the relevant configs may instead be defined within the logger 647 definition itself. 648 649 For example: 650 651 loggers: 652 PCOD: 653 configs: 654 'off': {} 655 net: 656 readers: 657 key1: value1 658 writers: 659 key2: value2 660 net+file: 661 readers: 662 key1: value1 663 writers: 664 key2: value2 665 666 In this case, the config names will have the logger name prepended 667 (e.g. 'off' becomes PCOD-off, net becomes PCOD-net, etc.) 668 669 Note that both methods may be used in a single cruise definition, though 670 for clarity, this is not advised. 671 """ 672 # Validate input 673 if 'loggers' not in input_dict: 674 raise ValueError("Input dictionary must have a 'loggers' key") 675 676 # Create a new dictionary to avoid modifying the input 677 result = copy.deepcopy(input_dict) 678 679 # Ensure configs key exists in the result 680 if 'configs' not in result: 681 result['configs'] = {} 682 683 # Process each logger 684 for logger_name, logger_data in input_dict['loggers'].items(): 685 # Skip if no configs key in this logger 686 if 'configs' not in logger_data: 687 continue 688 689 # Get the configs for this logger 690 logger_configs = logger_data['configs'] 691 692 # Handle the case where configs is a list of strings 693 if isinstance(logger_configs, list): 694 # Verify each referenced config exists in top-level configs 695 for config_name in logger_configs: 696 if config_name not in result['configs']: 697 raise ValueError(f"Referenced config '{config_name}' " 698 "not found in top-level configs") 699 700 # Handle the case where configs is a dictionary of dictionaries 701 elif isinstance(logger_configs, dict): 702 # Create a new config list for this logger 703 new_config_list = [] 704 705 # Process each config in this logger 706 for config_key, config_value in logger_configs.items(): 707 # Generate the new config name 708 config_name = f"{logger_name}-{config_key}" 709 710 # Add to the config list 711 new_config_list.append(config_name) 712 713 # Check for potential overwrites in the top-level configs 714 if config_name in result['configs']: 715 print(f"Warning: Overwriting existing config " 716 f"'{config_name}' in top-level configs") 717 718 # Add the config to the top-level configs 719 result['configs'][config_name] = config_value 720 721 # Replace the logger's configs dict with the list of config names 722 result['loggers'][logger_name]['configs'] = new_config_list 723 724 return result
Expand a configuration dictionary with loggers and configs structure.
This function processes a dictionary with a 'loggers' key (required) and an optional 'configs' key. It extracts config dictionaries from each logger and moves them to the top level 'configs' section, replacing them with a list of references.
Args: input_dict (dict): The input dictionary containing 'loggers' and optionally 'configs' keys.
Returns: dict: A new dictionary with expanded configuration structure.
Raises: ValueError: If the 'loggers' key is missing or if referenced configs are missing.
This code is to support flexibility in defining cruise configurations.
In the past, the "loggers" section of a cruise definition only allowed declaring the names of each configuration associated with a logger. The actual definition of each configuration had to be placed in a following top-level "configs" section.
For example:
loggers: PCOD: configs:
- PCOD-off
- PCOD-net
- PCOD-net+file cwnc: ...
configs: PCOD-off: {} PCOD-net: readers: key1: value1 writers: key2: value2 PCOD-net+file: readers: key1: value1 writers: key2: value2
The old declaration-followed-by-definition method still works, but now, if desired, the relevant configs may instead be defined within the logger definition itself.
For example:
loggers: PCOD: configs: 'off': {} net: readers: key1: value1 writers: key2: value2 net+file: readers: key1: value1 writers: key2: value2
In this case, the config names will have the logger name prepended (e.g. 'off' becomes PCOD-off, net becomes PCOD-net, etc.)
Note that both methods may be used in a single cruise definition, though for clarity, this is not advised.
728def expand_modes(input_dict): 729 """ 730 Expand or infer the modes section of a cruise definition dict. 731 732 This function processes a dictionary with a 'loggers' key (required) and 733 an optional 'configs' key. It extracts config dictionaries from each 734 logger and moves them to the top level 'configs' section, replacing them 735 with a list of references. 736 737 Args: 738 input_dict (dict): The input dictionary (possibly) containing 'modes' 739 and 'configs' keys. 740 741 Returns: 742 dict: A new dictionary with expanded configuration structure. 743 744 Raises: 745 ValueError: If the 'configs' key is missing or if referenced configs 746 are missing. 747 748 ### This code is to support flexibility in defining cruise configurations. 749 750 In the past, cruise modes were required to be dicts mapping a logger name 751 to config. We can infer that dict from a simple list of configs. 752 """ 753 # Validate input 754 if 'loggers' not in input_dict: 755 raise ValueError("Cruise definition missing loggers?!?") 756 if 'configs' not in input_dict: 757 raise ValueError("Cruise definition missing configs?!?") 758 759 # No modes defined (or an empty 'modes' declaration)? Create a default one 760 modes = input_dict.get('modes') 761 if not modes: 762 logging.warning('No "modes" section found. Generating default mode.') 763 return generate_default_mode(input_dict) 764 765 # 'modes' is there. Is it a dict? 766 if not isinstance(modes, dict): 767 raise ValueError(f"'modes' definition must be a dict of modes. " 768 f"Found {type(modes)}") 769 770 # This is the copy we're going to modify and return 771 result = copy.deepcopy(input_dict) 772 773 loggers = input_dict.get('loggers') 774 for mode_name, mode_configs in input_dict.get('modes').items(): 775 # Mode is already in expanded form - nothing to do 776 if isinstance(mode_configs, dict): 777 continue 778 779 if not isinstance(mode_configs, list): 780 raise ValueError(f"Mode {mode_name} must be either dict " 781 f"or list; found {type(mode_configs)}") 782 783 # If here, we've got a list of configs that should be run in this mode. 784 # Figure out which loggers they belong to and expand into the normal 785 # dict form. 786 mode_dict = {} 787 for config_name in mode_configs: 788 # Look through loggers for this config_name 789 found = False 790 for logger_name, logger_def in loggers.items(): 791 logger_configs = logger_def.get('configs') 792 if not logger_configs: 793 raise ValueError(f"Logger {logger_name} has no configs!") 794 if config_name in logger_configs: 795 mode_dict[logger_name] = config_name 796 found = True 797 break 798 if not found: 799 raise ValueError(f"No logger found for {config_name} in " 800 f"mode {mode_name}") 801 802 # Now confirm that each logger has had a config defined 803 for logger_name in loggers: 804 if logger_name not in mode_dict: 805 raise ValueError(f"No config defined for {logger_name} " 806 f"in mode {mode_name}") 807 808 # Replace the config list with newly-created config dict 809 result['modes'][mode_name] = mode_dict 810 811 # Is there a default mode defined? If not, pick the first one in the 812 # dict and define it as the default. 813 if 'default_mode' not in result: 814 first_mode = next(iter(result.get('modes'))) 815 result['default_mode'] = first_mode 816 817 return result
Expand or infer the modes section of a cruise definition dict.
This function processes a dictionary with a 'loggers' key (required) and an optional 'configs' key. It extracts config dictionaries from each logger and moves them to the top level 'configs' section, replacing them with a list of references.
Args: input_dict (dict): The input dictionary (possibly) containing 'modes' and 'configs' keys.
Returns: dict: A new dictionary with expanded configuration structure.
Raises: ValueError: If the 'configs' key is missing or if referenced configs are missing.
This code is to support flexibility in defining cruise configurations.
In the past, cruise modes were required to be dicts mapping a logger name to config. We can infer that dict from a simple list of configs.
821def generate_default_mode(input_dict): 822 """ 823 If no 'modes' section is present in input_dict, create one that has a 824 single mode, named 'default', using the first config defined for each 825 logger. 826 827 Args: 828 input_dict (dict): The input dictionary containing 'loggers' and 829 optionally 'configs' keys. 830 831 Returns: 832 dict: A new dictionary with modes and default_mode keys. 833 834 Raises: 835 ValueError: If the 'loggers' key is missing or if referenced configs 836 are missing. 837 """ 838 839 # Now it's time to check up on modes - do we actually have a modes key? 840 if 'modes' in input_dict: 841 return input_dict 842 843 # If not, create one. 844 # Create a new dictionary to avoid modifying the input 845 result = copy.deepcopy(input_dict) 846 default_mode = {} 847 848 for logger_name, logger_data in input_dict['loggers'].items(): 849 # Skip if no configs key in this logger 850 if 'configs' not in logger_data: 851 raise ValueError(f"Logger {logger_name} has no configs") 852 # Get the configs for this logger 853 logger_configs = logger_data['configs'] 854 855 # Handle the case where configs is a list of strings 856 if not isinstance(logger_configs, list) or not len(logger_configs): 857 raise ValueError(f"Logger {logger_name} config list is not a " 858 f"list? Found type {type(logger_configs)}: " 859 f"{logger_configs}") 860 default_mode[logger_name] = logger_configs[0] 861 result['modes'] = {'default': default_mode} 862 result['default_mode'] = 'default' 863 864 return result
If no 'modes' section is present in input_dict, create one that has a single mode, named 'default', using the first config defined for each logger.
Args: input_dict (dict): The input dictionary containing 'loggers' and optionally 'configs' keys.
Returns: dict: A new dictionary with modes and default_mode keys.
Raises: ValueError: If the 'loggers' key is missing or if referenced configs are missing.
868def find_unmatched_variables(data: Union[Dict, List, str, Any]) -> List[str]: 869 """ 870 Recursively searches through a nested data structure (dicts, lists, 871 strings) and finds all variables that begin with "<<" and end with ">>", 872 returning them with the brackets intact. These typically represent template 873 variables. 874 875 Args: 876 data: A dict, list, string, or other value to search through 877 878 Returns: 879 List of extracted strings with "<<" and ">>" included 880 """ 881 results = [] 882 883 if isinstance(data, dict): 884 # Search through dictionary keys and values 885 for key, value in data.items(): 886 # Check if key is a string that might contain bracketed strings 887 if isinstance(key, str): 888 results.extend(_extract_from_string(key)) 889 890 # Recursively check the value 891 results.extend(find_unmatched_variables(value)) 892 893 elif isinstance(data, list): 894 # Search through list elements 895 for item in data: 896 results.extend(find_unmatched_variables(item)) 897 898 elif isinstance(data, str): 899 # Search within the string 900 results.extend(_extract_from_string(data)) 901 902 # Return unique results (no duplicates) 903 return list(set(results))
Recursively searches through a nested data structure (dicts, lists, strings) and finds all variables that begin with "<<" and end with ">>", returning them with the brackets intact. These typically represent template variables.
Args: data: A dict, list, string, or other value to search through
Returns: List of extracted strings with "<<" and ">>" included
922def load_definitions(definition_path): 923 """ 924 Load and merge device definitions from YAML files. 925 926 This is a shared utility used by RegexParser and RecordParser to load 927 device and device_type definitions from YAML configuration files. 928 929 Supports both the new structured format and the legacy format: 930 931 New format: 932 devices: 933 device_name: 934 device_type: SomeType 935 device_types: 936 SomeType: 937 format: ... 938 939 Legacy format (deprecated): 940 device_name: 941 category: device 942 device_type: SomeType 943 SomeType: 944 category: device_type 945 format: ... 946 947 Args: 948 definition_path: Comma-separated glob patterns for definition files. 949 Example: 'local/devices/*.yaml,contrib/devices/*.yaml' 950 951 Returns: 952 Dict with structure: 953 { 954 'devices': {device_name: device_def, ...}, 955 'device_types': {type_name: type_def, ...} 956 } 957 958 Returns empty structure if definition_path is None or no files found. 959 """ 960 definitions = {'devices': {}, 'device_types': {}} 961 962 if not definition_path: 963 return definitions 964 965 def_files = [] 966 for path_glob in definition_path.split(','): 967 matched = glob.glob(path_glob.strip()) 968 if not matched: 969 logging.debug('No files match definition path "%s"', path_glob.strip()) 970 def_files.extend(matched) 971 972 if not def_files: 973 return definitions 974 975 for filename in def_files: 976 file_defs = read_config(filename) 977 file_defs = expand_includes(file_defs) 978 979 for key, val in file_defs.items(): 980 # New format: 'devices' key contains dict of device definitions 981 if key == 'devices': 982 if not isinstance(val, dict): 983 logging.error('"devices" value in file %s must be dict. ' 984 'Found type "%s"', filename, type(val)) 985 continue 986 for name, defn in val.items(): 987 if name in definitions['devices']: 988 logging.warning('Duplicate device definition "%s" in %s', 989 name, filename) 990 definitions['devices'][name] = defn 991 992 # New format: 'device_types' key contains dict of device type definitions 993 elif key == 'device_types': 994 if not isinstance(val, dict): 995 logging.error('"device_types" value in file %s must be dict. ' 996 'Found type "%s"', filename, type(val)) 997 continue 998 for name, defn in val.items(): 999 if name in definitions['device_types']: 1000 logging.warning('Duplicate device_type definition "%s" in %s', 1001 name, filename) 1002 definitions['device_types'][name] = defn 1003 1004 # Skip 'includes' - already handled by expand_includes 1005 elif key == 'includes': 1006 pass 1007 1008 # Legacy format: top-level key with 'category' field 1009 elif isinstance(val, dict) and 'category' in val: 1010 category = val.get('category') 1011 if category == 'device': 1012 if key in definitions['devices']: 1013 logging.warning('Duplicate device definition "%s" in %s', 1014 key, filename) 1015 definitions['devices'][key] = val 1016 elif category == 'device_type': 1017 if key in definitions['device_types']: 1018 logging.warning('Duplicate device_type definition "%s" in %s', 1019 key, filename) 1020 definitions['device_types'][key] = val 1021 else: 1022 logging.warning('Top-level definition "%s" in file %s has ' 1023 'unrecognized category "%s" - ignoring', 1024 key, filename, category) 1025 1026 # Unknown top-level key 1027 else: 1028 logging.debug('Ignoring unknown top-level key "%s" in %s', key, filename) 1029 1030 return definitions
Load and merge device definitions from YAML files.
This is a shared utility used by RegexParser and RecordParser to load device and device_type definitions from YAML configuration files.
Supports both the new structured format and the legacy format:
New format: devices: device_name: device_type: SomeType device_types: SomeType: format: ...
Legacy format (deprecated): device_name: category: device device_type: SomeType SomeType: category: device_type format: ...
Args: definition_path: Comma-separated glob patterns for definition files. Example: 'local/devices/.yaml,contrib/devices/.yaml'
Returns: Dict with structure: { 'devices': {device_name: device_def, ...}, 'device_types': {type_name: type_def, ...} }
Returns empty structure if definition_path is None or no files found.