diff --git a/ssg/profiles.py b/ssg/profiles.py new file mode 100644 index 00000000000..d2a1af208d7 --- /dev/null +++ b/ssg/profiles.py @@ -0,0 +1,349 @@ +from __future__ import absolute_import +from __future__ import print_function + +import os +import sys +import yaml + +from .controls import ControlsManager, Policy +from .products import ( + get_profile_files_from_root, + load_product_yaml, + product_yaml_path, +) + + +if sys.version_info >= (3, 9): + dict_type = dict # Python 3.9+ supports built-in generics + list_type = list + tuple_type = tuple +else: + from typing import Dict as dict_type # Fallback for older versions + from typing import List as list_type + from typing import Tuple as tuple_type + + +class ProfileSelections: + """ + A class to represent profile with sections of rules and variables. + + Attributes: + ----------- + profile_id : str + The unique identifier for the profile. + profile_title : str + The profile title associated with the profile id. + product_id : str + The product id associated with the profile. + product_title : str + The product title associated with the product id. + """ + def __init__(self, profile_id, profile_title, product_id, product_title): + self.profile_id = profile_id + self.profile_title = profile_title + self.product_id = product_id + self.product_title = product_title + self.rules = [] + self.unselected_rules = [] + self.variables = {} + + +def _load_product_yaml(content_dir: str, product: str) -> object: + """ + Load the product YAML file and return its content as a Python object. + + Args: + content_dir (str): The directory where the content is stored. + product (str): The name of the product. + + Returns: + object: The loaded YAML content as a Python object. + """ + file_yaml_path = product_yaml_path(content_dir, product) + return load_product_yaml(file_yaml_path) + + +def _load_yaml_profile_file(file_path: str) -> dict_type: + """ + Load the content of a YAML file intended to profiles definitions. + + It is not necessary to process macros in this case. + + Args: + file_path (str): The path to the YAML file. + + Returns: + dict: The content of the YAML file as a dictionary. + """ + with open(file_path, 'r') as file: + try: + return yaml.safe_load(file) + except yaml.YAMLError as e: + print(f"Error loading YAML profile file {file_path}: {e}") + return {} + + +def _get_extended_profile_path(profiles_files: list, profile_name: str) -> str: + """ + Retrieve the full path of a profile file from a list of profile file paths. + + Args: + profiles_files (list of str): A list of file paths where profile files are located. + profile_name (str): The name of the profile to search for. + + Returns: + str: The full path of the profile file if found, otherwise None. + """ + profile_file = f"{profile_name}.profile" + profile_path = next((path for path in profiles_files if profile_file in path), None) + return profile_path + + +def _process_profile_extension(profile: ProfileSelections, profile_yaml: dict, + profiles_files: list, policies: dict) -> ProfileSelections: + """ + Processes the extension of a profile by recursively checking if the profile extends another + profile and updating the profile selections accordingly. + + Args: + profile (ProfileSelections): The profile object to be processed. + profile_yaml (dict): The YAML content of the current profile. + profiles_files (list): List of profile file paths. + policies (dict): The policies defined in the current profile. + + Returns: + ProfileSelections: The updated profile object. + """ + extended_profile = profile_yaml.get("extends") + if isinstance(extended_profile, str): + extended_profile = _get_extended_profile_path(profiles_files, extended_profile) + if extended_profile is not None: + profile_yaml = _load_yaml_profile_file(extended_profile) + return _process_profile(profile, profile_yaml, profiles_files, policies) + return profile + + +def _parse_control_line(control_line: str) -> tuple_type[str, str]: + """ + Parses a control line string and returns a tuple containing the first and third parts of the + string, separated by a colon. If the string does not contain three parts, the second element + of the tuple defaults to 'all'. + + Args: + control_line (str): The control line string to be parsed. + + Returns: + tuple[str, str]: A tuple containing the first part of the control line and either the + third part or 'all' if the third part is not present. + """ + parts = control_line.split(":") + if len(parts) == 3: + return parts[0], parts[2] + return parts[0], 'all' + + +def _process_selected_variable(profile: ProfileSelections, variable: str) -> None: + """ + Processes a selected variable and updates the profile's variables. + + Args: + profile (ProfileSelections): The profile object containing variables. + variable (str): The variable in the format 'name=value'. + + Raises: + ValueError: If the variable is not in the correct format. + """ + variable_name, variable_value = variable.split('=', 1) + if variable_name not in profile.variables: + profile.variables[variable_name] = variable_value + + +def _process_selected_rule(profile: ProfileSelections, rule: str) -> None: + """ + Adds a rule to the profile's selected rules if it is not already selected or unselected. + + Args: + profile (ProfileSelections): The profile containing selected and unselected rules. + rule (str): The rule to be added to the profile's selected rules. + + Returns: + None + """ + if rule not in profile.unselected_rules and rule not in profile.rules: + profile.rules.append(rule) + + +def _process_control(profile: ProfileSelections, control: object) -> None: + """ + Processes a control by iterating through its rules and applying the appropriate processing + function. Not that at this level rules list in control can include both variables and rules. + The function distinguishes between variable and rules based on the presence of an '=' + character in the rule. + + Args: + profile (ProfileSelections): The profile selections to be processed. + control: The control object containing rules to be processed. + """ + for rule in control.rules: + if "=" in rule: + _process_selected_variable(profile, rule) + else: + _process_selected_rule(profile, rule) + + +def _update_profile_with_policy(profile: ProfileSelections, policy: Policy, level: str) -> None: + """ + Updates the given profile with controls from the specified policy based on the provided level. + + Args: + profile (ProfileSelections): The profile to be updated. + policy (Policy): The policy containing controls to update the profile with. + level (str): The level of controls to be processed. If 'all', all controls are processed. + Otherwise, only controls matching the specified level are processed. + + Returns: + None + """ + for control in policy.controls: + if level == 'all' or level in control.levels: + _process_control(profile, control) + + +def _process_controls(profile: ProfileSelections, control_line: str, + policies: dict) -> ProfileSelections: + """ + Process a control file inheritance to update profile selections based on the given policies. + + Args: + profile (ProfileSelections): The profile object to be processed. + control_line (str): A string representing the control line, which contains a policy ID and + optionally a level, separated by colons. + policies (dict): A dictionary of policies, where each key is a policy ID and each value is + a policy object containing the controls. + + Returns: + ProfileSelections: The updated profile object. + """ + policy_id, level = _parse_control_line(control_line) + policy = policies.get(policy_id) + + if policy is None: + print(f"Policy {policy_id} not found") + return profile + + _update_profile_with_policy(profile, policy, level) + return profile + + +def _process_selections(profile: ProfileSelections, profile_yaml: dict, + policies: dict) -> ProfileSelections: + """ + Processes the selections from the profile YAML and updates the profile accordingly. + + Args: + profile (ProfileSelections): The profile object to be processed. + profile_yaml (dict): A dictionary containing the profile YAML data. + policies (dict): A dictionary containing policy information. + + Returns: + profile: The updated profile object. + """ + selections = profile_yaml.get("selections", []) + for selected in selections: + if selected.startswith("!"): + profile.unselected_rules.append(selected[1:]) + elif "=" in selected: + variable_name, variable_value = selected.split('=', 1) + profile.variables[variable_name] = variable_value + elif ":" in selected: + profile = _process_controls(profile, selected, policies) + else: + profile.rules.append(selected) + return profile + + +def _process_profile(profile: ProfileSelections, profile_yaml: dict, profiles_files: list, + policies: dict) -> ProfileSelections: + """ + Processes a profile by handling profile extensions, and processing selections. + + Args: + profile (ProfileSelections): The profile object to be processed. + profile_yaml (dict): The YAML content of the profile. + profiles_files (list): A list of profile file paths. + policies (dict): A dictionary of policies defined by control files. + + Returns: + ProfileSelections: The processed profile object. + """ + profile = _process_profile_extension(profile, profile_yaml, profiles_files, policies) + profile = _process_selections(profile, profile_yaml, policies) + return profile + + +def _load_controls_manager(controls_dir: str, product_yaml: dict) -> object: + """ + Loads and initializes a ControlsManager instance. + + Args: + controls_dir (str): The directory containing control files. + product_yaml (dict): The product configuration in YAML format. + + Returns: + object: An instance of ControlsManager with loaded controls. + """ + control_mgr = ControlsManager(controls_dir, product_yaml) + control_mgr.load() + return control_mgr + + +def _sort_profiles_selections(profiles: list) -> ProfileSelections: + """ + Sorts profiles selections (rules and variables) by selections ids. + + Args: + profiles (list): A list of ProfileSelections objects to be sorted. + + Returns: + ProfileSelections: The sorted list of ProfileSelections objects. + """ + for profile in profiles: + profile.rules = sorted(profile.rules) + profile.unselected_rules = sorted(profile.unselected_rules) + profile.variables = dict(sorted(profile.variables.items())) + return profiles + + +def get_profiles_from_products(content_dir: str, products: list, + sorted: bool = False) -> list_type: + """ + Retrieves profiles with respective variables from the given products. + + Args: + content_dir (str): The directory containing the content. + products (list): A list of product names to retrieve profiles from. + + Returns: + list: A list of ProfileVariables objects containing profile variables for each product. + """ + profiles = [] + controls_dir = os.path.join(content_dir, 'controls') + + for product in products: + product_yaml = _load_product_yaml(content_dir, product) + product_title = product_yaml.get("full_name") + profiles_files = get_profile_files_from_root(product_yaml, product_yaml) + controls_manager = _load_controls_manager(controls_dir, product_yaml) + for file in profiles_files: + profile_id = os.path.basename(file).split('.profile')[0] + profile_yaml = _load_yaml_profile_file(file) + profile_title = profile_yaml.get("title") + profile = ProfileSelections(profile_id, profile_title, product, product_title) + profile = _process_profile(profile, profile_yaml, profiles_files, + controls_manager.policies) + profiles.append(profile) + + if sorted: + profiles = _sort_profiles_selections(profiles) + + return profiles diff --git a/ssg/variables.py b/ssg/variables.py index 66757e01df1..3196ad9acf5 100644 --- a/ssg/variables.py +++ b/ssg/variables.py @@ -4,16 +4,10 @@ import glob import os import sys -import yaml from collections import defaultdict from .constants import BENCHMARKS -from .controls import ControlsManager -from .products import ( - get_profile_files_from_root, - load_product_yaml, - product_yaml_path, -) +from .profiles import get_profiles_from_products from .yaml import open_and_macro_expand @@ -25,8 +19,9 @@ from typing import Dict as dict_type -# Cache variable files to avoid multiple reads +# Cache variable files and respective content to avoid multiple reads _var_files_cache = {} +_vars_content_cache = {} def get_variable_files_in_folder(content_dir: str, subfolder: str) -> list_type[str]: @@ -69,26 +64,23 @@ def get_variable_files(content_dir: str) -> list_type[str]: return variable_files -def get_variable_options(content_dir: str, variable_id: str = None) -> dict_type: +def _get_variables_content(content_dir: str) -> dict_type: """ - Retrieve the options for specific or all variables from the content root directory. - - If `variable_id` is provided, returns options for that variable only. - If `variable_id` is not provided, returns a dictionary of all variable options. + Retrieve the content of all variable files from the specified content root directory. Args: content_dir (str): The root directory containing benchmark directories. - variable_id (str, optional): The ID of the variable to retrieve options for. - Defaults to None. Returns: - dict: If `variable_id` is None, a dictionary where keys are variable IDs and values are - their options. Otherwise, a dictionary of options for the specified variable. + dict: A dictionary where keys are variable IDs and values are the content of the variable + files. """ - all_variable_files = get_variable_files(content_dir) - all_options = {} + if content_dir in _vars_content_cache: + return _vars_content_cache[content_dir] - for var_file in all_variable_files: + variables_content = {} + + for var_file in get_variable_files(content_dir): try: yaml_content = open_and_macro_expand(var_file) except Exception as e: @@ -96,250 +88,70 @@ def get_variable_options(content_dir: str, variable_id: str = None) -> dict_type continue var_id = os.path.basename(var_file).split('.var')[0] - options = yaml_content.get("options", {}) - - if variable_id: - if var_id == variable_id: - return options - else: - all_options[var_id] = options - - if variable_id: - print(f"Variable {variable_id} not found") - return {} - - return all_options - - -class ProfileVariables: - """ - A class to represent profile variables. - - Attributes: - ----------- - profile_id : str - The unique identifier for the profile. - product : str - The product associated with the profile. - variables : dict - A dictionary containing the variables for the profile. - """ - def __init__(self, profile_id, product, variables): - self.profile_id = profile_id - self.product = product - self.variables = variables - - -def _load_product_yaml(content_dir: str, product: str) -> object: - """ - Load the product YAML file and return its content as a Python object. - - Args: - content_dir (str): The directory where the content is stored. - product (str): The name of the product. - - Returns: - object: The loaded YAML content as a Python object. - """ - file_yaml_path = product_yaml_path(content_dir, product) - return load_product_yaml(file_yaml_path) - - -def _load_yaml_profile_file(file_path: str) -> dict_type: - """ - Load the content of a YAML file intended to profiles definitions. - - It is not necessary to process macros in this case. - - Args: - file_path (str): The path to the YAML file. - - Returns: - dict: The content of the YAML file as a dictionary. - """ - with open(file_path, 'r') as file: - try: - return yaml.safe_load(file) - except yaml.YAMLError as e: - print(f"Error loading YAML profile file {file_path}: {e}") - return {} - - -def _get_extended_profile_path(profiles_files: list, profile_name: str) -> str: - """ - Retrieve the full path of a profile file from a list of profile file paths. - - Args: - profiles_files (list of str): A list of file paths where profile files are located. - profile_name (str): The name of the profile to search for. - - Returns: - str: The full path of the profile file if found, otherwise None. - """ - profile_file = f"{profile_name}.profile" - profile_path = next((path for path in profiles_files if profile_file in path), None) - return profile_path - - -def _process_profile_extension(profiles_files: list, profile_yaml: dict, - profile_variables: dict, policies: dict) -> dict_type: - """ - Processes the extension of a profile by recursively checking if the profile extends another - profile and updating the profile variables accordingly. - - Args: - profiles_files (list): List of profile file paths. - profile_yaml (dict): The YAML content of the current profile. - profile_variables (dict): The variables already defined in the current profile. - policies (dict): The policies defined in the current profile. - - Returns: - dict: The updated profile variables after processing the extended profile, - or the original profile variables if no extension is found. - """ - extended_profile = profile_yaml.get("extends") - if isinstance(extended_profile, str): - extended_profile = _get_extended_profile_path(profiles_files, extended_profile) - if extended_profile is not None: - return _process_profile(profiles_files, extended_profile, policies, profile_variables) - return profile_variables + variables_content[var_id] = yaml_content + _vars_content_cache[content_dir] = variables_content + return variables_content -def _process_controls(control_line: str, profile_variables: dict, policies: dict) -> dict_type: - """ - Process a control file inheritance to update profile variables based on the given policies. - - Args: - control_line (str): A string representing the control line, which contains a policy ID and - optionally a level, separated by colons. - profile_variables (dict): A dictionary of profile variables to be updated. - policies (dict): A dictionary of policies, where each key is a policy ID and each value is - a policy object containing the controls. - Returns: - dict: The updated profile variables dictionary. - - Raises: - KeyError: If the policy ID from the control line is not found in the policies dictionary. - """ - if control_line.count(":") == 2: - policy_id, _, level = control_line.split(":") - else: - policy_id, _ = control_line.split(":") - level = None - - try: - policy = policies[policy_id] - except KeyError: - print(f"Policy {policy_id} not found") - return profile_variables - - for control in policy.controls: - if level in control.levels: - for rule in control.rules: - if "=" in rule: - variable_name, variable_value = rule.split('=', 1) - # When a profile extends a control file, the variables explicitly defined in - # profiles files must be honored, so don't update variables already defined. - if variable_name not in profile_variables: - profile_variables[variable_name] = variable_value - return profile_variables - - -def _process_selections(profile_yaml: dict, profile_variables: dict, policies: dict) -> dict_type: +def get_variable_property(content_dir: str, variable_id: str, property_name: str) -> str: """ - Processes the selections from the profile YAML and updates the profile variables accordingly. + Retrieve a specific property of a variable from the content root directory. Args: - profile_yaml (dict): A dictionary containing the profile YAML data. - profile_variables (dict): A dictionary to store the profile variables. - policies (dict): A dictionary containing policy information. + content_dir (str): The root directory containing benchmark directories. + variable_id (str): The ID of the variable to retrieve the property for. + property_name (str): The name of the property to retrieve. Returns: - dict: The updated profile variables dictionary. - """ - selections = profile_yaml.get("selections") - for selected in selections: - if "=" in selected and "!" not in selected: - variable_name, variable_value = selected.split('=', 1) - profile_variables[variable_name] = variable_value - elif ":" in selected: - profile_variables = _process_controls(selected, profile_variables, policies) - return profile_variables - - -def _process_profile(profiles_files: list, file: str, policies: dict, - profile_variables={}) -> dict_type: + str: The value of the specified property for the variable. """ - Processes a profile by loading its YAML file, handling profile extensions, and processing - selections. + variables_content = _get_variables_content(content_dir) + variable_content = variables_content.get(variable_id, {}) + return variable_content.get(property_name, '') - Args: - profiles_files (list): A list of profile file paths. - file (str): The path to the profile file to be processed. - policies (dict): A dictionary of policies defined by control files. - profile_variables (dict, optional): A dictionary of profile variables. Defaults to empty. - Returns: - dict: A dictionary containing the processed profile variables. +def get_variable_options(content_dir: str, variable_id: str = None) -> dict_type: """ - profile_yaml = _load_yaml_profile_file(file) - profile_variables = _process_profile_extension(profiles_files, profile_yaml, - profile_variables, policies) - profile_variables = _process_selections(profile_yaml, profile_variables, policies) - return profile_variables - + Retrieve the options for specific or all variables from the content root directory. -def _load_controls_manager(controls_dir: str, product_yaml: dict) -> object: - """ - Loads and initializes a ControlsManager instance. + If `variable_id` is provided, returns options for that variable only. + If `variable_id` is not provided, returns a dictionary of all variables with their options. Args: - controls_dir (str): The directory containing control files. - product_yaml (dict): The product configuration in YAML format. + content_dir (str): The root directory containing benchmark directories. + variable_id (str, optional): The ID of the variable to retrieve options for. + Defaults to None. Returns: - object: An instance of ControlsManager with loaded controls. - """ - control_mgr = ControlsManager(controls_dir, product_yaml) - control_mgr.load() - return control_mgr - - -def _get_profiles_from_products(content_dir: str, products: list) -> list_type: + dict: If `variable_id` is None, a dictionary where keys are variable IDs and values are + their options. Otherwise, a dictionary of options for the specified variable. """ - Retrieves profiles with respective variables from the given products. + variables_content = _get_variables_content(content_dir) + all_options = {} - Args: - content_dir (str): The directory containing the content. - products (list): A list of product names to retrieve profiles from. + for var_id, var_yaml in variables_content.items(): + options = var_yaml.get("options", {}) - Returns: - list: A list of ProfileVariables objects containing profile variables for each product. - """ - profiles = [] - controls_dir = os.path.join(content_dir, 'controls') + if variable_id: + if var_id == variable_id: + return options + else: + all_options[var_id] = options - for product in products: - product_yaml = _load_product_yaml(content_dir, product) - profiles_files = get_profile_files_from_root(product_yaml, product_yaml) - controls_manager = _load_controls_manager(controls_dir, product_yaml) - for file in profiles_files: - profile_id = os.path.basename(file).split('.profile')[0] - profile_variables = _process_profile(profiles_files, file, controls_manager.policies) - profile = ProfileVariables(profile_id, product, profile_variables) - profiles.append(profile) + if variable_id: + print(f"Variable {variable_id} not found") + return {} - return profiles + return all_options -def _get_variables_from_profiles(profiles: list) -> dict_type: +def get_variables_from_profiles(profiles: list) -> dict_type: """ Extracts variables from a list of profiles and organizes them into a nested dictionary. Args: - profiles (list): A list of profile objects, each containing variables, product, and id - attributes. + profiles (list): A list of profile objects, each containing selections and id attributes. Returns: dict: A nested dictionary where the first level keys are variable names, the second level @@ -349,8 +161,8 @@ def _get_variables_from_profiles(profiles: list) -> dict_type: variables = defaultdict(lambda: defaultdict(dict)) for profile in profiles: for variable, value in profile.variables.items(): - variables[variable][profile.product][profile.profile_id] = value - return variables + variables[variable][profile.product_id][profile.profile_id] = value + return _convert_defaultdict_to_dict(variables) def _convert_defaultdict_to_dict(dictionary: defaultdict) -> dict_type: @@ -373,7 +185,8 @@ def get_variables_by_products(content_dir: str, products: list) -> dict_type[str Retrieve variables by products from the specified content root directory. This function collects profiles for the given products and extracts variables from these - profiles. + profiles. If you already have a list of Profiles obtained by get_profiles_from_products() + defined in profiles.py, consider to use get_variables_from_profiles() instead. Args: content_dir (str): The root directory of the content. @@ -383,8 +196,8 @@ def get_variables_by_products(content_dir: str, products: list) -> dict_type[str dict: A dictionary where keys are variable names and values are dictionaries of product-profile pairs. """ - profiles = _get_profiles_from_products(content_dir, products) - profiles_variables = _get_variables_from_profiles(profiles) + profiles = get_profiles_from_products(content_dir, products) + profiles_variables = get_variables_from_profiles(profiles) return _convert_defaultdict_to_dict(profiles_variables) diff --git a/tests/unit/ssg-module/test_profiles.py b/tests/unit/ssg-module/test_profiles.py new file mode 100644 index 00000000000..c1ac79afd64 --- /dev/null +++ b/tests/unit/ssg-module/test_profiles.py @@ -0,0 +1,40 @@ +import os +import pytest + +from ssg.products import ( + get_all, + get_profile_files_from_root, +) +from ssg.profiles import ( + get_profiles_from_products, + _load_product_yaml, +) + + +# The get_profiles_from_products function interacts with many objects and other functions that +# would be complex to mock. So it will be tested with a real content directory. To make it +# predictable, all existing products will be collected and the first rhel product will be used +# for testing. The decision to use a rhel product is that I am more used to them and I know their +# profiles also use control files. +content_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) + +def get_first_rhel_product_from_products_dir(): + products = get_all(content_dir) + rhel_products = [product for product in products.linux if "rhel" in product] + return rhel_products[0] + + +def count_profiles_in_products_dir(product): + product_yaml = _load_product_yaml(content_dir, product) + profiles_files = get_profile_files_from_root(product_yaml, product_yaml) + return len(profiles_files) + + +def test_get_profiles_from_products(): + products = [get_first_rhel_product_from_products_dir()] + profiles = get_profiles_from_products(content_dir, products, sorted=True) + + assert len(profiles) == count_profiles_in_products_dir(products[0]) + assert 'rhel' in profiles[0].product_id + assert len(profiles[0].rules) > 0 + assert len(profiles[0].variables) > 0 diff --git a/tests/unit/ssg-module/test_variables.py b/tests/unit/ssg-module/test_variables.py index 89e8c77a70b..1858b5d36bd 100644 --- a/tests/unit/ssg-module/test_variables.py +++ b/tests/unit/ssg-module/test_variables.py @@ -6,7 +6,9 @@ get_variable_files_in_folder, get_variable_files, get_variable_options, + get_variable_property, get_variables_by_products, + get_variables_from_profiles, get_variable_values, ) @@ -24,7 +26,10 @@ def setup_test_files(base_dir, benchmark_dirs, create_txt_file=False): path = base_dir / benchmark_dir os.makedirs(path, exist_ok=True) var_file = path / "test.var" - var_file.write_text("options:\n default: value\n option1: value1\n option2: value2\n") + var_file.write_text( + "options:\n default: value\n option1: value1\n option2: value2\n" + "title: Test Title\ndescription: Test Description\n" + ) if create_txt_file: txt_file = path / "test.txt" txt_file.write_text("options:\n option: value\n") @@ -79,3 +84,53 @@ def test_get_variable_values(tmp_path): result = get_variable_values(str(content_dir), profiles_variables) assert result["test"]["product1"]["profile1"] == "value1" assert result["test"]["product2"]["profile2"] == "value2" + + +def test_get_variables_from_profiles(): + class MockProfile: + def __init__(self, product_id, profile_id, variables): + self.product_id = product_id + self.profile_id = profile_id + self.variables = variables + + profiles = [ + MockProfile("product1", "profile1", {"var1": "value1", "var2": "value2"}), + MockProfile("product1", "profile2", {"var1": "value3"}), + MockProfile("product2", "profile1", {"var2": "value4"}), + ] + + expected_result = { + "var1": { + "product1": { + "profile1": "value1", + "profile2": "value3", + } + }, + "var2": { + "product1": { + "profile1": "value2", + }, + "product2": { + "profile1": "value4", + } + } + } + + result = get_variables_from_profiles(profiles) + assert result == expected_result + +def test_get_variable_property(tmp_path): + content_dir = tmp_path / "content" + benchmark_dirs = ["app", "app/rules"] + setup_test_files(content_dir, benchmark_dirs) + + result = get_variable_property(str(content_dir), "test", "title") + assert result == "Test Title" + + # Test for a non-existent property + result = get_variable_property(str(content_dir), "test", "non_existent_property") + assert result == "" + + # Test for a non-existent variable + result = get_variable_property(str(content_dir), "non_existent_variable", "property_name") + assert result == ""