From 4deaf8e37384655ef79bf146bb793b226275be86 Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:33:54 -0500 Subject: [PATCH 01/10] merge profiles taking the most restrictive requirement Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- redfish_interop_validator/profile.py | 107 +++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 15 deletions(-) diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index 8ba7857..ff1a391 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -58,21 +58,89 @@ def getProfilesMatchingName(name, directories): def dict_merge(dct, merge_dct): """ - https://gist.github.com/angstwad/bf22d1822c38a92ec0a9 modified - Recursive dict merge. Inspired by :meth:``dict.update()``, instead of - updating only top-level keys, dict_merge recurses down into dicts nested - to an arbitrary depth, updating keys. The ``merge_dct`` is merged into - ``dct``. + Smart recursive dict merge + + Chooses the most restrictive values when merging: + - MinVersion/MinCount: uses maximum (most restrictive) + - MaxVersion/MaxCount: uses minimum (most restrictive) + - ReadRequirement/WriteRequirement: uses hierarchy (Mandatory > Recommended > IfImplemented > Supported) + - Arrays: merges/unions instead of replacing + - ConditionalRequirements: merges lists + :param dct: dict onto which the merge is executed - :param merge_dct: dct merged into dct + :param merge_dct: dict merged into dct :return: None """ - for k in merge_dct: - if (k in dct and isinstance(dct[k], dict) - and isinstance(merge_dct[k], Mapping)): - dict_merge(dct[k], merge_dct[k]) - else: - dct[k] = merge_dct[k] + # Requirement hierarchy: higher value = more restrictive + REQUIREMENT_HIERARCHY = { + 'Mandatory': 4, + 'Recommended': 3, + 'IfImplemented': 2, + 'Supported': 1, + None: 4 # per DSP0272 when unspecified Requirement, it's considered Mandatory + } + + def get_more_restrictive_requirement(req1, req2, requirement_type='ReadRequirement'): + """Returns the more restrictive requirement + + For ReadRequirement: returns value with hierarchy 4 (Mandatory) + For WriteRequirement: returns value with hierarchy 0 (least restrictive) + """ + if requirement_type == 'ReadRequirement': + # Return the requirement with hierarchy value 4 (Mandatory) + for req in [req1, req2]: + if REQUIREMENT_HIERARCHY.get(req, 0) == 4: + return req + return req1 if req1 is not None else req2 + else: # WriteRequirement + # Return the requirement with hierarchy value 0 (least restrictive) + return min(req1, req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) + + def compare_versions(ver1, ver2, use_max=True): + """Compare two version strings and return the more restrictive one""" + if ver1 is None: + return ver2 + if ver2 is None: + return ver1 + + return max(ver1, ver2, key=lambda v: splitVersionString(str(v))) if use_max else min(ver1, ver2, key=lambda v: splitVersionString(str(v))) + + def merge_lists(list1, list2): + """Merge two lists, removing duplicates while preserving order""" + seen = set() + merged = [] + for item in list1 + list2: + item_key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else item + if item_key not in seen: + seen.add(item_key) + merged.append(item) + return merged + + for k, v in merge_dct.items(): + """Build merged profile based on more restrictive values""" + if k in ('ReadRequirement', 'WriteRequirement'): + # Handle missing keys as None (Mandatory for ReadRequirement per DSP0272) + dct[k] = get_more_restrictive_requirement(dct.get(k), v, requirement_type=k) + elif k not in dct: + dct[k] = v + elif k == 'MinVersion': + dct[k] = compare_versions(dct[k], v, use_max=True) + elif k == 'MaxVersion': + dct[k] = compare_versions(dct[k], v, use_max=False) + elif k == 'MinCount': + dct[k] = max(dct[k], v) + elif k == 'MaxCount': + dct[k] = min(dct[k], v) + elif isinstance(dct[k], dict) and isinstance(v, Mapping): + dict_merge(dct[k], v) + elif isinstance(dct[k], list) and isinstance(v, list): + dct[k] = merge_lists(dct[k], v) + elif type(dct[k]) != type(v): + my_logger.warning( + f'Type conflict during merge for key "{k}": ' + f'{type(dct[k]).__name__} vs {type(v).__name__}. ' + f'Keeping existing value.' + ) def updateWithProfile(profile, data): @@ -152,7 +220,7 @@ def parseProfileInclude(target_name, target_profile_info, directories, online): def getProfiles(profile, directories, chain=None, online=False): profile_includes, required_by_resource = [], [] - + # Prevent cyclical imports when possible profile_name = profile.get('ProfileName') if chain is None: @@ -162,13 +230,22 @@ def getProfiles(profile, directories, chain=None, online=False): return [], [] chain.append(profile_name) - # Gather all included profiles, these are each run independently in validateResource. - # TODO: Process them simultaneously in validateResource, to avoid polling the target machine multiple times + # Gather all included profiles and merge them into the main profile + # This processes them simultaneously to avoid polling the target machine multiple times required_profiles = profile.get('RequiredProfiles', {}) for target_name, target_profile_info in required_profiles.items(): profile_data = parseProfileInclude(target_name, target_profile_info, directories, online) if profile_data: + # Merge the included profile's Resources into the main profile + if 'Resources' not in profile: + profile['Resources'] = {} + included_resources = profile_data.get('Resources', {}) + for resource_type, resource_data in included_resources.items(): + if resource_type not in profile['Resources']: + profile['Resources'][resource_type] = {} + dict_merge(profile['Resources'][resource_type], resource_data) + profile_includes.append(profile_data) inner_includes, inner_reqs = getProfiles(profile_data, directories, chain) From 5e52bd6bf39a563c3a6db86d6f1eca093643d9f0 Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:12:34 -0500 Subject: [PATCH 02/10] show failures at summary level Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- redfish_interop_validator/tohtml.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/redfish_interop_validator/tohtml.py b/redfish_interop_validator/tohtml.py index dccd004..9fc827d 100644 --- a/redfish_interop_validator/tohtml.py +++ b/redfish_interop_validator/tohtml.py @@ -142,15 +142,18 @@ def renderHtml(results, tool_version, startTick, nowTick, service): for k, my_result in results.items(): for msg in my_result['messages']: - if msg.result in [testResultEnum.PASS]: + if msg.result == testResultEnum.PASS: summary['pass'] += 1 - if msg.result in [testResultEnum.NOT_TESTED]: + elif msg.result == testResultEnum.FAIL: + summary['error'] += 1 + elif msg.result == testResultEnum.WARN: + summary['warning'] += 1 + elif msg.result == testResultEnum.NOT_TESTED: summary['not_tested'] += 1 + # to avoid double counting, only count if record doesn't have a 'result' attribute for record in my_result['records']: - if record.levelname.lower() in ['error', 'warning']: + if not record.result and record.levelname.lower() in ['error', 'warning']: summary[record.levelname.lower()] += 1 - if record.result: - summary[record.result] += 1 important_block = tag.div('Results Summary') important_block += tag.div(", ".join([ @@ -232,10 +235,16 @@ def renderHtml(results, tool_version, startTick, nowTick, service): my_summary = Counter() for msg in my_result['messages']: - if msg.result in [testResultEnum.PASS]: + if msg.result == testResultEnum.PASS: my_summary['pass'] += 1 - if msg.result in [testResultEnum.NOT_TESTED]: + elif msg.result == testResultEnum.NOT_TESTED: my_summary['not_tested'] += 1 + elif msg.result == testResultEnum.FAIL: + my_summary['fail'] += 1 + elif msg.result == testResultEnum.WARN: + my_summary['warn'] += 1 + elif msg.result in [testResultEnum.NOPASS, testResultEnum.OK, testResultEnum.NA]: + my_summary[msg.result.value.lower()] += 1 for record in my_result['records']: if record.levelname.lower() in ['error', 'warning']: From 1ded3cd4d2eea493cf8c62423bc1883627d4ae37 Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:17:06 -0500 Subject: [PATCH 03/10] more merge profiles taking the most restrictive requirement Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- .../RedfishInteropValidator.py | 49 +++++++++---------- redfish_interop_validator/profile.py | 33 ++++++++----- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/redfish_interop_validator/RedfishInteropValidator.py b/redfish_interop_validator/RedfishInteropValidator.py index 05919a2..1616c9a 100644 --- a/redfish_interop_validator/RedfishInteropValidator.py +++ b/redfish_interop_validator/RedfishInteropValidator.py @@ -197,35 +197,32 @@ def main(argslist=None, configfile=None): profile_version = profile.get('ProfileVersion') # Create a list of profiles, required imports, and show their hashes + # Note: getProfiles() merges included profile resources into the parent profile included_profiles, required_by_resource = getProfiles(profile, [os.getcwd()] + my_paths, online=args.online_profiles) - all_profiles = [profile] + included_profiles - my_logger.info('Profile Hashes (included by {}): '.format(file_name)) for inner_profile in included_profiles: - inner_profile_name = profile.get('ProfileName') - inner_profile_version = profile.get('ProfileVersion') + inner_profile_name = inner_profile.get('ProfileName') + inner_profile_version = inner_profile.get('ProfileVersion') my_logger.info('\t{} {}, dict md5 hash: {}'.format(inner_profile_name, inner_profile_version, hashProfile(inner_profile))) my_logger.info('Profile Hashes (required by Resource): '.format(file_name)) for inner_profile in required_by_resource: - inner_profile_name = profile.get('ProfileName') - inner_profile_version = profile.get('ProfileVersion') + inner_profile_name = inner_profile.get('ProfileName') + inner_profile_version = inner_profile.get('ProfileVersion') my_logger.info('\t{} {}, dict md5 hash: {}'.format(inner_profile_name, inner_profile_version, hashProfile(inner_profile))) - for profile_to_process in all_profiles: - processing_profile_name = profile_to_process.get('ProfileName') - if processing_profile_name not in processed_profiles: - processed_profiles.add(profile_name) - else: - my_logger.warning("Import Warning: Profile {} already processed".format({})) + # Only validate the parent profile since included profiles are already merged into it + # Validating included profiles separately would cause duplicate URI traversals + if profile_name not in processed_profiles: + processed_profiles.add(profile_name) if 'single' in pmode: - success, new_results, _, _ = validateSingleURI(ppath, profile_to_process, 'Target', expectedJson=jsonData) + success, new_results, _, _ = validateSingleURI(ppath, profile, 'Target', expectedJson=jsonData) elif 'tree' in pmode: - success, new_results, _, _ = validateURITree(ppath, profile_to_process, 'Target', expectedJson=jsonData) + success, new_results, _, _ = validateURITree(ppath, profile, 'Target', expectedJson=jsonData) else: - success, new_results, _, _ = validateURITree('/redfish/v1/', profile_to_process, 'ServiceRoot', expectedJson=jsonData) + success, new_results, _, _ = validateURITree('/redfish/v1/', profile, 'ServiceRoot', expectedJson=jsonData) if results is None: results = new_results else: @@ -236,8 +233,8 @@ def main(argslist=None, configfile=None): results[item_name]['messages'].extend(item['messages']) else: results[item_name] = item - # resultsNew = {profileName+key: resultsNew[key] for key in resultsNew if key in results} - # results.update(resultsNew) + else: + my_logger.warning("Profile {} already processed, skipping".format(profile_name)) except traverseInterop.AuthenticationError as e: # log authentication error and terminate program my_logger.error('{}'.format(e)) @@ -259,10 +256,10 @@ def main(argslist=None, configfile=None): for k, my_result in results.items(): for msg in my_result['messages']: - if msg.result in [testResultEnum.PASS]: + if msg.result == testResultEnum.PASS: final_counts['pass'] += 1 - if msg.result in [testResultEnum.NOT_TESTED]: - final_counts['not_tested'] += 1 + elif msg.result == testResultEnum.NOT_TESTED: + final_counts['nottested'] += 1 warns = [x for x in my_result['records'] if x.levelno == logger.Level.WARN] errors = [x for x in my_result['records'] if x.levelno == logger.Level.ERROR] @@ -292,16 +289,16 @@ def main(argslist=None, configfile=None): my_logger.info("\nResults Summary:") my_logger.info(", ".join([ - 'Pass: {}'.format(final_counts['pass']), - 'Fail: {}'.format(final_counts['error']), - 'Warning: {}'.format(final_counts['warning']), - 'Not Tested: {}'.format(final_counts['nottested']), + 'Pass: {}'.format(final_counts.get('pass', 0)), + 'Fail: {}'.format(final_counts.get('fail', 0) + final_counts.get('error', 0)), + 'Warning: {}'.format(final_counts.get('warn', 0) + final_counts.get('warning', 0)), + 'Not Tested: {}'.format(final_counts.get('nottested', 0)), ])) - success = final_counts['error'] == 0 + success = (final_counts.get('fail', 0) + final_counts.get('error', 0)) == 0 if not success: - my_logger.error("Validation has failed: {} problems found".format(final_counts['error'])) + my_logger.error("Validation has failed: {} problems found".format(final_counts.get('fail', 0) + final_counts.get('error', 0))) else: my_logger.info("Validation has succeeded.") status_code = 0 diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index ff1a391..92dcc62 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -77,24 +77,29 @@ def dict_merge(dct, merge_dct): 'Recommended': 3, 'IfImplemented': 2, 'Supported': 1, - None: 4 # per DSP0272 when unspecified Requirement, it's considered Mandatory } def get_more_restrictive_requirement(req1, req2, requirement_type='ReadRequirement'): """Returns the more restrictive requirement - For ReadRequirement: returns value with hierarchy 4 (Mandatory) - For WriteRequirement: returns value with hierarchy 0 (least restrictive) + For ReadRequirement: returns the requirement with highest hierarchy (Mandatory > Recommended > IfImplemented > Supported) + Unspecified (None) is treated as "Mandatory" per DSP0272 + For WriteRequirement: returns the requirement with lowest hierarchy (least restrictive) + Unspecified (None) stays as None + + Per DSP0272: + - ReadRequirement not specified = Mandatory + - WriteRequirement not specified = None (no write requirement) """ if requirement_type == 'ReadRequirement': - # Return the requirement with hierarchy value 4 (Mandatory) - for req in [req1, req2]: - if REQUIREMENT_HIERARCHY.get(req, 0) == 4: - return req - return req1 if req1 is not None else req2 - else: # WriteRequirement - # Return the requirement with hierarchy value 0 (least restrictive) - return min(req1, req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) + # Normalize None to "Mandatory" for ReadRequirement + normalized_req1 = "Mandatory" if req1 is None else req1 + normalized_req2 = "Mandatory" if req2 is None else req2 + # Return the requirement with the highest hierarchy value (most restrictive) + return max(normalized_req1, normalized_req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) + else: + # For WriteRequirement, None stays as None + return max(req1, req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) def compare_versions(ver1, ver2, use_max=True): """Compare two version strings and return the more restrictive one""" @@ -142,6 +147,12 @@ def merge_lists(list1, list2): f'Keeping existing value.' ) + # Handle implicit requirements: if merge_dct has content (defines the resource) + # but doesn't explicitly specify ReadRequirement, treat it as Mandatory per DSP0272 + if merge_dct and 'ReadRequirement' not in merge_dct and 'ReadRequirement' in dct: + # merge_dct defines this resource but didn't specify ReadRequirement (implicitly Mandatory) + dct['ReadRequirement'] = get_more_restrictive_requirement(dct['ReadRequirement'], None, requirement_type='ReadRequirement') + def updateWithProfile(profile, data): dict_merge(data, profile) From f172b26c240518b84016f79ac6488426195ff2bb Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:27:52 -0500 Subject: [PATCH 04/10] add concurrency Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- redfish_interop_validator/validateResource.py | 145 ++++++++++++------ 1 file changed, 101 insertions(+), 44 deletions(-) diff --git a/redfish_interop_validator/validateResource.py b/redfish_interop_validator/validateResource.py index 23cf8c4..82b660d 100644 --- a/redfish_interop_validator/validateResource.py +++ b/redfish_interop_validator/validateResource.py @@ -5,6 +5,8 @@ import logging import re from io import StringIO +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading import redfish_interop_validator.traverseInterop as traverseInterop import redfish_interop_validator.interop as interop @@ -231,14 +233,24 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non message_list.append(msg) currentLinks = [(link, links[link], resource_obj) for link in links] + # Get max_workers from config, default to 10 + max_workers = traverseInterop.config.get('max_workers', 10) + results_lock = threading.Lock() + # todo : churning a lot of links, causing possible slowdown even with set checks while len(currentLinks) > 0: newLinks = list() - for linkName, link, parent in currentLinks: + # Filter links to process (skip already visited, fragments, etc.) + links_to_process = [] + for linkName, link, parent in currentLinks: if link is None or link.rstrip('/') in allLinks: continue - + + # TODO: !!!!!!! remove hardcoded skips (make configurable???) These take a long time + are unnecessary tests + if 'TelemetryService' in link or 'Oem' in link or '/JsonSchemas' in link or '/Registries' in link or 'SecureBoot' in link or 'JobService' in link: + continue + if '#' in link: # NOTE: Skips referenced Links (using pound signs), this program currently only works with direct links continue @@ -247,51 +259,96 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non refLinks.append((linkName, link, parent)) continue - # NOTE: unable to determine autoexpanded resources without Schema - else: - linkSuccess, linkResults, inner_links, linkobj = \ - validateSingleURI(link, profile, linkName, parent=parent) - - allLinks.add(link.rstrip('/')) + links_to_process.append((linkName, link, parent)) - results.update(linkResults) - - if not linkSuccess: - continue - - inner_links, inner_limited_links = inner_links - - for skipped_link in inner_limited_links: - allLinks.add(inner_limited_links[skipped_link]) - - innerLinksTuple = [(link, inner_links[link], linkobj) for link in inner_links] - newLinks.extend(innerLinksTuple) - SchemaType = getType(linkobj.jsondata.get('@odata.type', 'NoType')) - - subordinate_tree = [] - - current_parent = linkobj.parent - while current_parent: - parentType = getType(current_parent.jsondata.get('@odata.type', 'NoType')) - subordinate_tree.append(parentType) - current_parent = current_parent.parent - - # Search for UseCase.USECASENAME - usecases_found = [msg.name.split('.')[-1] for msg in linkResults[linkName]['messages'] if 'UseCase' == msg.name.split('.')[0]] + # Skip parallel processing if no links to process + if not links_to_process: + if refLinks is not currentLinks and len(newLinks) == 0 and len(refLinks) > 0: + currentLinks = refLinks + else: + currentLinks = newLinks + continue - if resource_stats.get(SchemaType) is None: - resource_stats[SchemaType] = { - "Exists": True, - "Writeable": False, - "URIsFound": [link.rstrip('/')], - "SubordinateTo": set([tuple(reversed(subordinate_tree))]), - "UseCasesFound": set(usecases_found), + # Process links in parallel using ThreadPoolExecutor + try: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all validation tasks + future_to_link = { + executor.submit(validateSingleURI, link, profile, linkName, parent=parent): (linkName, link, parent) + for linkName, link, parent in links_to_process } - else: - resource_stats[SchemaType]['Exists'] = True - resource_stats[SchemaType]['URIsFound'].append(link.rstrip('/')) - resource_stats[SchemaType]['SubordinateTo'].add(tuple(reversed(subordinate_tree))) - resource_stats[SchemaType]['UseCasesFound'] = resource_stats[SchemaType]['UseCasesFound'].union(usecases_found) + + # Process results as they complete + for future in as_completed(future_to_link): + linkName, link, parent = future_to_link[future] + + try: + linkSuccess, linkResults, inner_links, linkobj = future.result() + except KeyboardInterrupt: + # Re-raise keyboard interrupt for graceful shutdown + raise + except traverseInterop.AuthenticationError as e: + my_logger.warning(f'Authentication error for {link}: {repr(e)}') + # Mark link as visited even on failure to avoid retry loops + with results_lock: + allLinks.add(link.rstrip('/')) + continue + except Exception as e: + my_logger.error(f'Exception during parallel validation of {link}: {repr(e)}') + # Mark link as visited even on failure to avoid retry loops + with results_lock: + allLinks.add(link.rstrip('/')) + continue + + # Thread-safe updates to shared state + with results_lock: + allLinks.add(link.rstrip('/')) + results.update(linkResults) + + if not linkSuccess: + continue + + inner_links, inner_limited_links = inner_links + + with results_lock: + for skipped_link in inner_limited_links: + allLinks.add(inner_limited_links[skipped_link]) + + innerLinksTuple = [(link, inner_links[link], linkobj) for link in inner_links] + + # Thread-safe update to newLinks + with results_lock: + newLinks.extend(innerLinksTuple) + + SchemaType = getType(linkobj.jsondata.get('@odata.type', 'NoType')) + + subordinate_tree = [] + + current_parent = linkobj.parent + while current_parent: + parentType = getType(current_parent.jsondata.get('@odata.type', 'NoType')) + subordinate_tree.append(parentType) + current_parent = current_parent.parent + + usecases_found = [msg.name.split('.')[-1] for msg in linkResults[linkName]['messages'] if 'UseCase' == msg.name.split('.')[0]] + + with results_lock: + if resource_stats.get(SchemaType) is None: + resource_stats[SchemaType] = { + "Exists": True, + "Writeable": False, + "URIsFound": [link.rstrip('/')], + "SubordinateTo": set([tuple(reversed(subordinate_tree))]), + "UseCasesFound": set(usecases_found), + } + else: + resource_stats[SchemaType]['Exists'] = True + resource_stats[SchemaType]['URIsFound'].append(link.rstrip('/')) + resource_stats[SchemaType]['SubordinateTo'].add(tuple(reversed(subordinate_tree))) + resource_stats[SchemaType]['UseCasesFound'] = resource_stats[SchemaType]['UseCasesFound'].union(usecases_found) + except KeyboardInterrupt: + # Re-raise keyboard interrupt for graceful shutdown + raise if refLinks is not currentLinks and len(newLinks) == 0 and len(refLinks) > 0: currentLinks = refLinks From cd82fd0f705132d7cfe8a8c4c39467927df07ede Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:07:51 -0500 Subject: [PATCH 05/10] unmerge profiles Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- redfish_interop_validator/profile.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index 92dcc62..5f12fcc 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -241,31 +241,17 @@ def getProfiles(profile, directories, chain=None, online=False): return [], [] chain.append(profile_name) - # Gather all included profiles and merge them into the main profile - # This processes them simultaneously to avoid polling the target machine multiple times required_profiles = profile.get('RequiredProfiles', {}) for target_name, target_profile_info in required_profiles.items(): profile_data = parseProfileInclude(target_name, target_profile_info, directories, online) if profile_data: - # Merge the included profile's Resources into the main profile - if 'Resources' not in profile: - profile['Resources'] = {} - included_resources = profile_data.get('Resources', {}) - for resource_type, resource_data in included_resources.items(): - if resource_type not in profile['Resources']: - profile['Resources'][resource_type] = {} - dict_merge(profile['Resources'][resource_type], resource_data) - profile_includes.append(profile_data) inner_includes, inner_reqs = getProfiles(profile_data, directories, chain) profile_includes.extend(inner_includes) required_by_resource.extend(inner_reqs) - - # Process all RequiredResourceProfile by modifying profiles profile_resources = profile.get('Resources', {}) - for resource_name, resource in profile_resources.items(): # Modify just the resource or its UseCases. Should not have concurrent UseCases and RequiredResourceProfile in Resource if 'UseCases' not in resource: @@ -280,9 +266,7 @@ def getProfiles(profile, directories, chain=None, online=False): if profile_data: target_resources = profile_data.get('Resources') - # Merge if our data exists if resource_name in target_resources: - dict_merge(inner_object, target_resources[resource_name]) required_by_resource.append(profile_data) else: my_logger.error('RequiredProfiles Import Error: Import {} does not have Resource {}'.format(target_name, resource_name)) From f739b9a3f2500a1dd5972552e98f1ffceb0887b0 Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:10:12 -0500 Subject: [PATCH 06/10] display failures in summary Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- .../RedfishInteropValidator.py | 58 +++++++++++-------- redfish_interop_validator/profile.py | 15 +++++ redfish_interop_validator/tohtml.py | 15 ++++- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/redfish_interop_validator/RedfishInteropValidator.py b/redfish_interop_validator/RedfishInteropValidator.py index 1616c9a..e09fc2a 100644 --- a/redfish_interop_validator/RedfishInteropValidator.py +++ b/redfish_interop_validator/RedfishInteropValidator.py @@ -196,8 +196,6 @@ def main(argslist=None, configfile=None): profile_name = profile.get('ProfileName') profile_version = profile.get('ProfileVersion') - # Create a list of profiles, required imports, and show their hashes - # Note: getProfiles() merges included profile resources into the parent profile included_profiles, required_by_resource = getProfiles(profile, [os.getcwd()] + my_paths, online=args.online_profiles) my_logger.info('Profile Hashes (included by {}): '.format(file_name)) @@ -212,29 +210,36 @@ def main(argslist=None, configfile=None): inner_profile_version = inner_profile.get('ProfileVersion') my_logger.info('\t{} {}, dict md5 hash: {}'.format(inner_profile_name, inner_profile_version, hashProfile(inner_profile))) - # Only validate the parent profile since included profiles are already merged into it - # Validating included profiles separately would cause duplicate URI traversals - if profile_name not in processed_profiles: - processed_profiles.add(profile_name) - - if 'single' in pmode: - success, new_results, _, _ = validateSingleURI(ppath, profile, 'Target', expectedJson=jsonData) - elif 'tree' in pmode: - success, new_results, _, _ = validateURITree(ppath, profile, 'Target', expectedJson=jsonData) - else: - success, new_results, _, _ = validateURITree('/redfish/v1/', profile, 'ServiceRoot', expectedJson=jsonData) - if results is None: - results = new_results + all_profiles_to_validate = [profile] + included_profiles + required_by_resource + + for current_profile in all_profiles_to_validate: + current_profile_name = current_profile.get('ProfileName') + current_profile_version = current_profile.get('ProfileVersion') + profile_id = f"{current_profile_name}_{current_profile_version}" + + if profile_id not in processed_profiles: + processed_profiles.add(profile_id) + my_logger.info('Validating profile: {} {}'.format(current_profile_name, current_profile_version)) + + if 'single' in pmode: + success, new_results, _, _ = validateSingleURI(ppath, current_profile, 'Target', expectedJson=jsonData) + elif 'tree' in pmode: + success, new_results, _, _ = validateURITree(ppath, current_profile, 'Target', expectedJson=jsonData) + else: + success, new_results, _, _ = validateURITree('/redfish/v1/', current_profile, 'ServiceRoot', expectedJson=jsonData) + + if results is None: + results = new_results + else: + for item_name, item in new_results.items(): + for x in item['messages']: + x.name = current_profile_name + ' -- ' + x.name + if item_name in results: + results[item_name]['messages'].extend(item['messages']) + else: + results[item_name] = item else: - for item_name, item in new_results.items(): - for x in item['messages']: - x.name = profile_name + ' -- ' + x.name - if item_name in results: - results[item_name]['messages'].extend(item['messages']) - else: - results[item_name] = item - else: - my_logger.warning("Profile {} already processed, skipping".format(profile_name)) + my_logger.info("Profile {} {} already processed, skipping".format(current_profile_name, current_profile_version)) except traverseInterop.AuthenticationError as e: # log authentication error and terminate program my_logger.error('{}'.format(e)) @@ -260,6 +265,9 @@ def main(argslist=None, configfile=None): final_counts['pass'] += 1 elif msg.result == testResultEnum.NOT_TESTED: final_counts['nottested'] += 1 + elif msg.result == testResultEnum.FAIL: + # Count failures by their specific name for detailed summary + final_counts[msg.name] += 1 warns = [x for x in my_result['records'] if x.levelno == logger.Level.WARN] errors = [x for x in my_result['records'] if x.levelno == logger.Level.ERROR] @@ -281,7 +289,7 @@ def main(argslist=None, configfile=None): import redfish_interop_validator.tohtml as tohtml - html_str = tohtml.renderHtml(results, tool_version, start_tick, now_tick, currentService) + html_str = tohtml.renderHtml(results, final_counts, tool_version, start_tick, now_tick, currentService) lastResultsPage = datetime.strftime(start_tick, os.path.join(logpath, "InteropHtmlLog_%m_%d_%Y_%H%M%S.html")) diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index 5f12fcc..14b44d4 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -241,17 +241,31 @@ def getProfiles(profile, directories, chain=None, online=False): return [], [] chain.append(profile_name) + # Gather all included profiles and merge them into the main profile + # This processes them simultaneously to avoid polling the target machine multiple times required_profiles = profile.get('RequiredProfiles', {}) for target_name, target_profile_info in required_profiles.items(): profile_data = parseProfileInclude(target_name, target_profile_info, directories, online) if profile_data: + # Merge the included profile's Resources into the main profile + if 'Resources' not in profile: + profile['Resources'] = {} + included_resources = profile_data.get('Resources', {}) + for resource_type, resource_data in included_resources.items(): + if resource_type not in profile['Resources']: + profile['Resources'][resource_type] = {} + dict_merge(profile['Resources'][resource_type], resource_data) + profile_includes.append(profile_data) inner_includes, inner_reqs = getProfiles(profile_data, directories, chain) profile_includes.extend(inner_includes) required_by_resource.extend(inner_reqs) + + # Process all RequiredResourceProfile by modifying profiles profile_resources = profile.get('Resources', {}) + for resource_name, resource in profile_resources.items(): # Modify just the resource or its UseCases. Should not have concurrent UseCases and RequiredResourceProfile in Resource if 'UseCases' not in resource: @@ -267,6 +281,7 @@ def getProfiles(profile, directories, chain=None, online=False): if profile_data: target_resources = profile_data.get('Resources') if resource_name in target_resources: + dict_merge(inner_object, target_resources[resource_name]) required_by_resource.append(profile_data) else: my_logger.error('RequiredProfiles Import Error: Import {} does not have Resource {}'.format(target_name, resource_name)) diff --git a/redfish_interop_validator/tohtml.py b/redfish_interop_validator/tohtml.py index 9fc827d..54c6ff1 100644 --- a/redfish_interop_validator/tohtml.py +++ b/redfish_interop_validator/tohtml.py @@ -77,7 +77,7 @@ def applyInfoSuccessColor(num, entry): return tag.div(entry, attr=style) -def renderHtml(results, tool_version, startTick, nowTick, service): +def renderHtml(results, finalCounts, tool_version, startTick, nowTick, service): # Render html config = service.config config_str = ', '.join(sorted(list(config.keys() - set(['systeminfo', 'targetip', 'password', 'description'])))) @@ -164,6 +164,19 @@ def renderHtml(results, tool_version, startTick, nowTick, service): ])) htmlStrBodyHeader += tag.tr(tag.td(important_block, 'class="center"')) + infos_left, infos_right = dict(), dict() + for key in sorted(finalCounts.keys()): + if finalCounts.get(key) == 0: + continue + if len(infos_left) <= len(infos_right): + infos_left[key] = finalCounts[key] + else: + infos_right[key] = finalCounts[key] + + htmlStrCounts = (tag.div(infoBlock(infos_left), 'class=\'column log\'') + tag.div(infoBlock(infos_right), 'class=\'column log\'')) + + htmlStrBodyHeader += tag.tr(tag.td(htmlStrCounts)) + infos = {x: config[x] for x in config if x not in ['systeminfo', 'ip', 'password', 'description']} infos_left, infos_right = dict(), dict() for key in sorted(infos.keys()): From 55439ea88d6f6170655140d96a016e73e409b3bf Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:04:50 -0500 Subject: [PATCH 07/10] enrich report with proper data Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- .../RedfishInteropValidator.py | 7 +++++++ redfish_interop_validator/tohtml.py | 15 ++++++--------- redfish_interop_validator/validateResource.py | 9 +++++---- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/redfish_interop_validator/RedfishInteropValidator.py b/redfish_interop_validator/RedfishInteropValidator.py index e09fc2a..41ba9c4 100644 --- a/redfish_interop_validator/RedfishInteropValidator.py +++ b/redfish_interop_validator/RedfishInteropValidator.py @@ -236,6 +236,13 @@ def main(argslist=None, configfile=None): x.name = current_profile_name + ' -- ' + x.name if item_name in results: results[item_name]['messages'].extend(item['messages']) + # Update timing and payload info if it wasn't set before + if results[item_name].get('rtime') == 'n/a' and item.get('rtime') != 'n/a': + results[item_name]['rtime'] = item['rtime'] + if not results[item_name].get('payload') and item.get('payload'): + results[item_name]['payload'] = item['payload'] + if results[item_name].get('rcode') == 0 and item.get('rcode') != 0: + results[item_name]['rcode'] = item['rcode'] else: results[item_name] = item else: diff --git a/redfish_interop_validator/tohtml.py b/redfish_interop_validator/tohtml.py index 54c6ff1..f325afb 100644 --- a/redfish_interop_validator/tohtml.py +++ b/redfish_interop_validator/tohtml.py @@ -164,16 +164,13 @@ def renderHtml(results, finalCounts, tool_version, startTick, nowTick, service): ])) htmlStrBodyHeader += tag.tr(tag.td(important_block, 'class="center"')) - infos_left, infos_right = dict(), dict() - for key in sorted(finalCounts.keys()): - if finalCounts.get(key) == 0: - continue - if len(infos_left) <= len(infos_right): - infos_left[key] = finalCounts[key] - else: - infos_right[key] = finalCounts[key] + # Filter non-zero counts and split into two columns + non_zero_items = [(k, v) for k, v in sorted(finalCounts.items()) if v != 0] + infos_left = dict(non_zero_items[::2]) # every other item starting at 0 + infos_right = dict(non_zero_items[1::2]) # every other item starting at 1 - htmlStrCounts = (tag.div(infoBlock(infos_left), 'class=\'column log\'') + tag.div(infoBlock(infos_right), 'class=\'column log\'')) + htmlStrCounts = (tag.div(infoBlock(infos_left), 'class=\'column log\'') + + tag.div(infoBlock(infos_right), 'class=\'column log\'')) htmlStrBodyHeader += tag.tr(tag.td(htmlStrCounts)) diff --git a/redfish_interop_validator/validateResource.py b/redfish_interop_validator/validateResource.py index 82b660d..d08d4c4 100644 --- a/redfish_interop_validator/validateResource.py +++ b/redfish_interop_validator/validateResource.py @@ -210,7 +210,7 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non links, limited_links = links if links else ({}, {}) for skipped_link in limited_links: - allLinks.add(limited_links[skipped_link]) + allLinks.add(limited_links[skipped_link].rstrip('/')) if resource_obj: SchemaType = getType(resource_obj.jsondata.get('@odata.type', 'NoType')) @@ -233,8 +233,8 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non message_list.append(msg) currentLinks = [(link, links[link], resource_obj) for link in links] - # Get max_workers from config, default to 10 - max_workers = traverseInterop.config.get('max_workers', 10) + # Get max_workers from config with default of 100 + max_workers = traverseInterop.config.get('max_workers', 100) results_lock = threading.Lock() # todo : churning a lot of links, causing possible slowdown even with set checks @@ -259,6 +259,7 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non refLinks.append((linkName, link, parent)) continue + allLinks.add(link.rstrip('/')) links_to_process.append((linkName, link, parent)) # Skip parallel processing if no links to process @@ -312,7 +313,7 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non with results_lock: for skipped_link in inner_limited_links: - allLinks.add(inner_limited_links[skipped_link]) + allLinks.add(inner_limited_links[skipped_link].rstrip('/')) innerLinksTuple = [(link, inner_links[link], linkobj) for link in inner_links] From 7f990f9959a42c65f15da1461a56382bbb0d11cf Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:51:59 -0500 Subject: [PATCH 08/10] Revert "more merge profiles taking the most restrictive requirement" This reverts commit ebae8a8a510b1683a580b98b50142a68932c7641. Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- .../RedfishInteropValidator.py | 92 ++++++++----------- redfish_interop_validator/profile.py | 33 +++---- 2 files changed, 51 insertions(+), 74 deletions(-) diff --git a/redfish_interop_validator/RedfishInteropValidator.py b/redfish_interop_validator/RedfishInteropValidator.py index 41ba9c4..b7d1962 100644 --- a/redfish_interop_validator/RedfishInteropValidator.py +++ b/redfish_interop_validator/RedfishInteropValidator.py @@ -196,57 +196,48 @@ def main(argslist=None, configfile=None): profile_name = profile.get('ProfileName') profile_version = profile.get('ProfileVersion') + # Create a list of profiles, required imports, and show their hashes included_profiles, required_by_resource = getProfiles(profile, [os.getcwd()] + my_paths, online=args.online_profiles) + all_profiles = [profile] + included_profiles + my_logger.info('Profile Hashes (included by {}): '.format(file_name)) for inner_profile in included_profiles: - inner_profile_name = inner_profile.get('ProfileName') - inner_profile_version = inner_profile.get('ProfileVersion') + inner_profile_name = profile.get('ProfileName') + inner_profile_version = profile.get('ProfileVersion') my_logger.info('\t{} {}, dict md5 hash: {}'.format(inner_profile_name, inner_profile_version, hashProfile(inner_profile))) my_logger.info('Profile Hashes (required by Resource): '.format(file_name)) for inner_profile in required_by_resource: - inner_profile_name = inner_profile.get('ProfileName') - inner_profile_version = inner_profile.get('ProfileVersion') + inner_profile_name = profile.get('ProfileName') + inner_profile_version = profile.get('ProfileVersion') my_logger.info('\t{} {}, dict md5 hash: {}'.format(inner_profile_name, inner_profile_version, hashProfile(inner_profile))) - all_profiles_to_validate = [profile] + included_profiles + required_by_resource - - for current_profile in all_profiles_to_validate: - current_profile_name = current_profile.get('ProfileName') - current_profile_version = current_profile.get('ProfileVersion') - profile_id = f"{current_profile_name}_{current_profile_version}" - - if profile_id not in processed_profiles: - processed_profiles.add(profile_id) - my_logger.info('Validating profile: {} {}'.format(current_profile_name, current_profile_version)) - - if 'single' in pmode: - success, new_results, _, _ = validateSingleURI(ppath, current_profile, 'Target', expectedJson=jsonData) - elif 'tree' in pmode: - success, new_results, _, _ = validateURITree(ppath, current_profile, 'Target', expectedJson=jsonData) - else: - success, new_results, _, _ = validateURITree('/redfish/v1/', current_profile, 'ServiceRoot', expectedJson=jsonData) - - if results is None: - results = new_results - else: - for item_name, item in new_results.items(): - for x in item['messages']: - x.name = current_profile_name + ' -- ' + x.name - if item_name in results: - results[item_name]['messages'].extend(item['messages']) - # Update timing and payload info if it wasn't set before - if results[item_name].get('rtime') == 'n/a' and item.get('rtime') != 'n/a': - results[item_name]['rtime'] = item['rtime'] - if not results[item_name].get('payload') and item.get('payload'): - results[item_name]['payload'] = item['payload'] - if results[item_name].get('rcode') == 0 and item.get('rcode') != 0: - results[item_name]['rcode'] = item['rcode'] - else: - results[item_name] = item + for profile_to_process in all_profiles: + processing_profile_name = profile_to_process.get('ProfileName') + if processing_profile_name not in processed_profiles: + processed_profiles.add(profile_name) + else: + my_logger.warning("Import Warning: Profile {} already processed".format({})) + + if 'single' in pmode: + success, new_results, _, _ = validateSingleURI(ppath, profile_to_process, 'Target', expectedJson=jsonData) + elif 'tree' in pmode: + success, new_results, _, _ = validateURITree(ppath, profile_to_process, 'Target', expectedJson=jsonData) + else: + success, new_results, _, _ = validateURITree('/redfish/v1/', profile_to_process, 'ServiceRoot', expectedJson=jsonData) + if results is None: + results = new_results else: - my_logger.info("Profile {} {} already processed, skipping".format(current_profile_name, current_profile_version)) + for item_name, item in new_results.items(): + for x in item['messages']: + x.name = profile_name + ' -- ' + x.name + if item_name in results: + results[item_name]['messages'].extend(item['messages']) + else: + results[item_name] = item + # resultsNew = {profileName+key: resultsNew[key] for key in resultsNew if key in results} + # results.update(resultsNew) except traverseInterop.AuthenticationError as e: # log authentication error and terminate program my_logger.error('{}'.format(e)) @@ -268,13 +259,10 @@ def main(argslist=None, configfile=None): for k, my_result in results.items(): for msg in my_result['messages']: - if msg.result == testResultEnum.PASS: + if msg.result in [testResultEnum.PASS]: final_counts['pass'] += 1 - elif msg.result == testResultEnum.NOT_TESTED: - final_counts['nottested'] += 1 - elif msg.result == testResultEnum.FAIL: - # Count failures by their specific name for detailed summary - final_counts[msg.name] += 1 + if msg.result in [testResultEnum.NOT_TESTED]: + final_counts['not_tested'] += 1 warns = [x for x in my_result['records'] if x.levelno == logger.Level.WARN] errors = [x for x in my_result['records'] if x.levelno == logger.Level.ERROR] @@ -304,16 +292,16 @@ def main(argslist=None, configfile=None): my_logger.info("\nResults Summary:") my_logger.info(", ".join([ - 'Pass: {}'.format(final_counts.get('pass', 0)), - 'Fail: {}'.format(final_counts.get('fail', 0) + final_counts.get('error', 0)), - 'Warning: {}'.format(final_counts.get('warn', 0) + final_counts.get('warning', 0)), - 'Not Tested: {}'.format(final_counts.get('nottested', 0)), + 'Pass: {}'.format(final_counts['pass']), + 'Fail: {}'.format(final_counts['error']), + 'Warning: {}'.format(final_counts['warning']), + 'Not Tested: {}'.format(final_counts['not_tested']), ])) - success = (final_counts.get('fail', 0) + final_counts.get('error', 0)) == 0 + success = final_counts['error'] == 0 if not success: - my_logger.error("Validation has failed: {} problems found".format(final_counts.get('fail', 0) + final_counts.get('error', 0))) + my_logger.error("Validation has failed: {} problems found".format(final_counts['error'])) else: my_logger.info("Validation has succeeded.") status_code = 0 diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index 14b44d4..e5c14d3 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -77,29 +77,24 @@ def dict_merge(dct, merge_dct): 'Recommended': 3, 'IfImplemented': 2, 'Supported': 1, + None: 4 # per DSP0272 when unspecified Requirement, it's considered Mandatory } def get_more_restrictive_requirement(req1, req2, requirement_type='ReadRequirement'): """Returns the more restrictive requirement - For ReadRequirement: returns the requirement with highest hierarchy (Mandatory > Recommended > IfImplemented > Supported) - Unspecified (None) is treated as "Mandatory" per DSP0272 - For WriteRequirement: returns the requirement with lowest hierarchy (least restrictive) - Unspecified (None) stays as None - - Per DSP0272: - - ReadRequirement not specified = Mandatory - - WriteRequirement not specified = None (no write requirement) + For ReadRequirement: returns value with hierarchy 4 (Mandatory) + For WriteRequirement: returns value with hierarchy 0 (least restrictive) """ if requirement_type == 'ReadRequirement': - # Normalize None to "Mandatory" for ReadRequirement - normalized_req1 = "Mandatory" if req1 is None else req1 - normalized_req2 = "Mandatory" if req2 is None else req2 - # Return the requirement with the highest hierarchy value (most restrictive) - return max(normalized_req1, normalized_req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) - else: - # For WriteRequirement, None stays as None - return max(req1, req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) + # Return the requirement with hierarchy value 4 (Mandatory) + for req in [req1, req2]: + if REQUIREMENT_HIERARCHY.get(req, 0) == 4: + return req + return req1 if req1 is not None else req2 + else: # WriteRequirement + # Return the requirement with hierarchy value 0 (least restrictive) + return min(req1, req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) def compare_versions(ver1, ver2, use_max=True): """Compare two version strings and return the more restrictive one""" @@ -147,12 +142,6 @@ def merge_lists(list1, list2): f'Keeping existing value.' ) - # Handle implicit requirements: if merge_dct has content (defines the resource) - # but doesn't explicitly specify ReadRequirement, treat it as Mandatory per DSP0272 - if merge_dct and 'ReadRequirement' not in merge_dct and 'ReadRequirement' in dct: - # merge_dct defines this resource but didn't specify ReadRequirement (implicitly Mandatory) - dct['ReadRequirement'] = get_more_restrictive_requirement(dct['ReadRequirement'], None, requirement_type='ReadRequirement') - def updateWithProfile(profile, data): dict_merge(data, profile) From b98198c23dff9d9252ed707f70bd4eb71bca378f Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:21:13 -0500 Subject: [PATCH 09/10] do not merge profiles Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- redfish_interop_validator/profile.py | 109 +++--------------- redfish_interop_validator/validateResource.py | 2 +- 2 files changed, 17 insertions(+), 94 deletions(-) diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index e5c14d3..2bb724c 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -58,89 +58,21 @@ def getProfilesMatchingName(name, directories): def dict_merge(dct, merge_dct): """ - Smart recursive dict merge - - Chooses the most restrictive values when merging: - - MinVersion/MinCount: uses maximum (most restrictive) - - MaxVersion/MaxCount: uses minimum (most restrictive) - - ReadRequirement/WriteRequirement: uses hierarchy (Mandatory > Recommended > IfImplemented > Supported) - - Arrays: merges/unions instead of replacing - - ConditionalRequirements: merges lists - + https://gist.github.com/angstwad/bf22d1822c38a92ec0a9 modified + Recursive dict merge. Inspired by :meth:``dict.update()``, instead of + updating only top-level keys, dict_merge recurses down into dicts nested + to an arbitrary depth, updating keys. The ``merge_dct`` is merged into + `dct``. :param dct: dict onto which the merge is executed - :param merge_dct: dict merged into dct + :param merge_dct: dct merged into dct :return: None """ - # Requirement hierarchy: higher value = more restrictive - REQUIREMENT_HIERARCHY = { - 'Mandatory': 4, - 'Recommended': 3, - 'IfImplemented': 2, - 'Supported': 1, - None: 4 # per DSP0272 when unspecified Requirement, it's considered Mandatory - } - - def get_more_restrictive_requirement(req1, req2, requirement_type='ReadRequirement'): - """Returns the more restrictive requirement - - For ReadRequirement: returns value with hierarchy 4 (Mandatory) - For WriteRequirement: returns value with hierarchy 0 (least restrictive) - """ - if requirement_type == 'ReadRequirement': - # Return the requirement with hierarchy value 4 (Mandatory) - for req in [req1, req2]: - if REQUIREMENT_HIERARCHY.get(req, 0) == 4: - return req - return req1 if req1 is not None else req2 - else: # WriteRequirement - # Return the requirement with hierarchy value 0 (least restrictive) - return min(req1, req2, key=lambda r: REQUIREMENT_HIERARCHY.get(r, 0)) - - def compare_versions(ver1, ver2, use_max=True): - """Compare two version strings and return the more restrictive one""" - if ver1 is None: - return ver2 - if ver2 is None: - return ver1 - - return max(ver1, ver2, key=lambda v: splitVersionString(str(v))) if use_max else min(ver1, ver2, key=lambda v: splitVersionString(str(v))) - - def merge_lists(list1, list2): - """Merge two lists, removing duplicates while preserving order""" - seen = set() - merged = [] - for item in list1 + list2: - item_key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else item - if item_key not in seen: - seen.add(item_key) - merged.append(item) - return merged - - for k, v in merge_dct.items(): - """Build merged profile based on more restrictive values""" - if k in ('ReadRequirement', 'WriteRequirement'): - # Handle missing keys as None (Mandatory for ReadRequirement per DSP0272) - dct[k] = get_more_restrictive_requirement(dct.get(k), v, requirement_type=k) - elif k not in dct: - dct[k] = v - elif k == 'MinVersion': - dct[k] = compare_versions(dct[k], v, use_max=True) - elif k == 'MaxVersion': - dct[k] = compare_versions(dct[k], v, use_max=False) - elif k == 'MinCount': - dct[k] = max(dct[k], v) - elif k == 'MaxCount': - dct[k] = min(dct[k], v) - elif isinstance(dct[k], dict) and isinstance(v, Mapping): - dict_merge(dct[k], v) - elif isinstance(dct[k], list) and isinstance(v, list): - dct[k] = merge_lists(dct[k], v) - elif type(dct[k]) != type(v): - my_logger.warning( - f'Type conflict during merge for key "{k}": ' - f'{type(dct[k]).__name__} vs {type(v).__name__}. ' - f'Keeping existing value.' - ) + for k in merge_dct: + if (k in dct and isinstance(dct[k], dict) + and isinstance(merge_dct[k], Mapping)): + dict_merge(dct[k], merge_dct[k]) + else: + dct[k] = merge_dct[k] def updateWithProfile(profile, data): @@ -230,22 +162,13 @@ def getProfiles(profile, directories, chain=None, online=False): return [], [] chain.append(profile_name) - # Gather all included profiles and merge them into the main profile - # This processes them simultaneously to avoid polling the target machine multiple times + # Gather all included profiles, these are each run independently in validateResource. + # TODO: Process them simultaneously in validateResource, to avoid polling the target machine multiple times required_profiles = profile.get('RequiredProfiles', {}) for target_name, target_profile_info in required_profiles.items(): profile_data = parseProfileInclude(target_name, target_profile_info, directories, online) if profile_data: - # Merge the included profile's Resources into the main profile - if 'Resources' not in profile: - profile['Resources'] = {} - included_resources = profile_data.get('Resources', {}) - for resource_type, resource_data in included_resources.items(): - if resource_type not in profile['Resources']: - profile['Resources'][resource_type] = {} - dict_merge(profile['Resources'][resource_type], resource_data) - profile_includes.append(profile_data) inner_includes, inner_reqs = getProfiles(profile_data, directories, chain) @@ -254,9 +177,8 @@ def getProfiles(profile, directories, chain=None, online=False): # Process all RequiredResourceProfile by modifying profiles profile_resources = profile.get('Resources', {}) - for resource_name, resource in profile_resources.items(): - # Modify just the resource or its UseCases. Should not have concurrent UseCases and RequiredResourceProfile in Resource + # Modify just the resource or its UseCases. Should not have concurrent UseCases and RequiredResourceProfile in Resource if 'UseCases' not in resource: modifying_objects = [resource] else: @@ -269,6 +191,7 @@ def getProfiles(profile, directories, chain=None, online=False): if profile_data: target_resources = profile_data.get('Resources') + # Merge if our data exists if resource_name in target_resources: dict_merge(inner_object, target_resources[resource_name]) required_by_resource.append(profile_data) diff --git a/redfish_interop_validator/validateResource.py b/redfish_interop_validator/validateResource.py index d08d4c4..4560939 100644 --- a/redfish_interop_validator/validateResource.py +++ b/redfish_interop_validator/validateResource.py @@ -234,7 +234,7 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non currentLinks = [(link, links[link], resource_obj) for link in links] # Get max_workers from config with default of 100 - max_workers = traverseInterop.config.get('max_workers', 100) + max_workers = traverseInterop.config.get('max_workers', 50) results_lock = threading.Lock() # todo : churning a lot of links, causing possible slowdown even with set checks From f123729acc0d17992ec618c9ab26f01dd52ade98 Mon Sep 17 00:00:00 2001 From: Sandy Li <47486464+iamsli@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:39:42 -0500 Subject: [PATCH 10/10] remove dev hack Signed-off-by: Sandy Li <47486464+iamsli@users.noreply.github.com> --- redfish_interop_validator/profile.py | 2 +- redfish_interop_validator/validateResource.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/redfish_interop_validator/profile.py b/redfish_interop_validator/profile.py index 2bb724c..066212e 100644 --- a/redfish_interop_validator/profile.py +++ b/redfish_interop_validator/profile.py @@ -62,7 +62,7 @@ def dict_merge(dct, merge_dct): Recursive dict merge. Inspired by :meth:``dict.update()``, instead of updating only top-level keys, dict_merge recurses down into dicts nested to an arbitrary depth, updating keys. The ``merge_dct`` is merged into - `dct``. + ``dct``. :param dct: dict onto which the merge is executed :param merge_dct: dct merged into dct :return: None diff --git a/redfish_interop_validator/validateResource.py b/redfish_interop_validator/validateResource.py index 4560939..9d98c5e 100644 --- a/redfish_interop_validator/validateResource.py +++ b/redfish_interop_validator/validateResource.py @@ -247,10 +247,6 @@ def validateURITree(URI, profile, uriName, expectedType=None, expectedSchema=Non if link is None or link.rstrip('/') in allLinks: continue - # TODO: !!!!!!! remove hardcoded skips (make configurable???) These take a long time + are unnecessary tests - if 'TelemetryService' in link or 'Oem' in link or '/JsonSchemas' in link or '/Registries' in link or 'SecureBoot' in link or 'JobService' in link: - continue - if '#' in link: # NOTE: Skips referenced Links (using pound signs), this program currently only works with direct links continue