fix(csr): a registration's attributes as attributeNames or as the deprecated propertyNames and relationshipNames

A registration's attributes may come back as attributeNames or as the deprecated propertyNames and relationshipNames pair.

Spec: TS 104 175 clause 5.2.6.6.5: attributeNames is a synonym for the combination of propertyNames and relationshipNames, which are both deprecated. A broker may return either form.

Change: compare_dictionaries_ignoring_keys (libraries/assertionUtils.py) folds a propertyNames/relationshipNames pair into attributeNames on both sides before it compares them. It works on copies, so the original values are not changed. A different attribute name still gives a different list, so a real difference is still reported. The expectation files are unchanged.

Effect: 13 Test Cases now pass against a broker that returns attributeNames. On develop they fail. None breaks: 033_01_01, 033_01_04, 033_11, 036_03_01, 036_04_01, 036_05_01, 036_05_02, 037_01_01, 037_01_02, 037_04_01, 037_05_02, 037_06_01, 037_06_02. ContextSource: develop 64/115 pass, this branch 77/115.

Checks run: robocop format --check, robot --dryrun, the documentation generator, doc/tests (13/13), statistics.

🤖 Generated with Claude Code

Merge request reports

Loading
+4 −1
Changes for TP/NGSI-LD/ContextSource/Registration/RegisterContextSource/033_01.robot: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -52,7 +52,10 @@ Create Context Source Registration With Expiration Date
        Append To List    ${ignored_attributes}    mode    operations
    END

    Check Created Resource Set To    ${registration_payload}    ${response1.json()}    ${ignored_attributes}
    Check Created Context Source Registration Set To
    ...    ${registration_payload}
    ...    ${response1.json()}
    ...    ${ignored_attributes}

Delete Created Context Source Registrations
    Delete Context Source Registration    ${registration_id}
+32 −0
Changes for doc/analysis/checks.py: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -80,6 +80,8 @@ class Checks:
                Checks.check_pagination_prev_and_next_headers,
            'Check Created Resource Set To':
                Checks.check_created_resource_set_to,
            'Check Created Context Source Registration Set To':
                Checks.check_created_context_source_registration_set_to,
            'Check Updated Resource Set To':
                Checks.check_updated_resource_set_to,
            'Check Updated Resources Set To':
@@ -285,6 +287,10 @@ class Checks:
                'params': ['created_resource', 'response_body', 'ignored_keys'],
                'position': []
            },
            'Check Created Context Source Registration Set To': {
                'params': ['created_registration', 'response_body', 'ignored_keys'],
                'position': []
            },
            'Check Created Resources Set To': {
                'params': ['expected_resources', 'response_body', 'ignored_keys'],
                'position': []
@@ -1000,6 +1006,32 @@ class Checks:

        return response

    @staticmethod
    def check_created_context_source_registration_set_to(kwargs: list) -> str:
        expected_parameters = ['created_registration', 'response_body', 'ignored_keys']

        if 'ignored_keys' not in kwargs:
            kwargs['ignored_keys'] = '${None}'

        result = [x for x in kwargs if x not in expected_parameters]
        response = "Check Created Context Source Registration Set To"
        for key, value in kwargs.items():
            match key:
                case 'created_registration':
                    response = f"{response} and\n    Query Parameter: '{key}' set to '{value}'"
                case 'response_body':
                    response = f"{response} and\n    Query Parameter: '{key}' set to '{value}'"
                case 'ignored_keys':
                    response = f"{response} and\n    Query Parameter: '{key}' set to '{value}' list of keys"
                # If an exact match is not confirmed, this last case will be used if provided
                case 'checks':
                    pass
                case _:
                    raise Exception(f"ERROR, unexpected attribute '{result}', the attributes expected are "
                                    f"'{expected_parameters}', but received: {kwargs}")

        return response

    @staticmethod
    def check_created_resources_set_to(kwargs: list) -> str:
        expected_parameters = ['expected_resources', 'response_body', 'ignored_keys']
+55 −2
Changes for libraries/assertionUtils.py: 55 added lines, 2 removed lines.
Original line number Diff line number Diff line
import copy
from re import compile
from dataclasses import dataclass
from typing import Any
@@ -83,6 +84,54 @@ class SystemGeneratedTemporalPropertyOperator(BaseOperatorPlus):
        return obj


# A RegistrationInfo (an information[] element) may express its registered
# attributes either as the deprecated propertyNames/relationshipNames pair or as
# the single attributeNames list (TS 104 175 clause 5.2.6.6.5) - both are valid
# GET /csourceRegistrations output, since a broker need not store the
# property/relationship split. A RegistrationInfo rule, not a rule of NGSI-LD
# payloads in general: only the registration checks use it, through
# compare_context_source_registrations_ignoring_keys. It exists only while the
# pair is supported - delete both functions with it.
def canonicalise_registration_attribute_names(obj):
    """Recursively rewrite every dict carrying propertyNames/relationshipNames
    to the attributeNames form. Mutates and returns obj - pass a copy.
    With attributeNames present the pair is dropped ("only used if
    attributeNames is not present", TS 104 175 clause 5.2.6.6.5); without it
    the pair is merged into attributeNames. List order is left to DeepDiff's
    ignore_order."""
    if isinstance(obj, list):
        for item in obj:
            canonicalise_registration_attribute_names(item)
    elif isinstance(obj, dict):
        if 'propertyNames' in obj or 'relationshipNames' in obj:
            property_names = obj.pop('propertyNames', None) or []
            relationship_names = obj.pop('relationshipNames', None) or []
            if 'attributeNames' not in obj:
                obj['attributeNames'] = list(property_names) + list(relationship_names)
        for value in obj.values():
            canonicalise_registration_attribute_names(value)
    return obj


def compare_context_source_registrations_ignoring_keys(expected, actual, exclude_regex_paths,
                                                       ignore_core_context_version=False, group_by=None,
                                                       ignore_order=True):
    """Function exposed as a keyword to compare context source registrations (one, or a list):
    compare_dictionaries_ignoring_keys on copies of both sides with the deprecated
    propertyNames/relationshipNames pair rewritten to attributeNames
    (canonicalise_registration_attribute_names). A failed comparison logs the original
    payloads, not the rewritten copies."""
    return compare_dictionaries_ignoring_keys(
        canonicalise_registration_attribute_names(copy.deepcopy(expected)),
        canonicalise_registration_attribute_names(copy.deepcopy(actual)),
        exclude_regex_paths,
        ignore_core_context_version=ignore_core_context_version,
        group_by=group_by,
        ignore_order=ignore_order,
        display_expected=expected,
        display_actual=actual)


def compare_func(x, y, level=None):
    try:
        return x['id'] == y['id']
@@ -91,7 +140,7 @@ def compare_func(x, y, level=None):


def compare_dictionaries_ignoring_keys(expected, actual, exclude_regex_paths, ignore_core_context_version=False,
                                       group_by=None, ignore_order=True):
                                       group_by=None, ignore_order=True, display_expected=None, display_actual=None):
    """Function exposed as a keyword to compare two dictionaries
    :param expected: expected dictionary
    :param actual: actual dictionary
@@ -99,6 +148,8 @@ def compare_dictionaries_ignoring_keys(expected, actual, exclude_regex_paths, ig
    :param ignore_core_context_version: whether any core context version is allowed in the results
    :param group_by: a key to group the results, useful for lists of results
    :param ignore_order: whether the order of list values is ignored
    :param display_expected: what a failed comparison logs as the expected side (default: expected)
    :param display_actual: what a failed comparison logs as the actual side (default: actual)

    threshold_to_diff_deeper=0: since deepdiff 8, when more than 1/3 of a dict's keys
    differ, DeepDiff reports the whole dict as changed instead of descending into it,
@@ -150,7 +201,9 @@ def compare_dictionaries_ignoring_keys(expected, actual, exclude_regex_paths, ig
                       ])

    if len(res) > 0:
        output_pretty_diff(expected, actual, Theme(added="", removed="", reset=""))
        output_pretty_diff(expected if display_expected is None else display_expected,
                           actual if display_actual is None else display_actual,
                           Theme(added="", removed="", reset=""))

    return res

+10 −2
Changes for resources/AssertionUtils.resource: 10 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -345,7 +345,7 @@ Check Response Body Containing Context Source Registration element
    ${expectation_json_with_id}=    Add Object To JSON    ${expectation_json}    $    ${id_dict}

    ${ignored_keys}=    Create List    ${context_regex_expr}    mode    operations
    ${comparison_result}=    Compare Dictionaries Ignoring Keys
    ${comparison_result}=    Compare Context Source Registrations Ignoring Keys
    ...    ${expectation_json_with_id}
    ...    ${response_body}
    ...    ${ignored_keys}
@@ -424,7 +424,7 @@ Check Response Body Containing List Containing Context Source Registrations elem
        Append To List    ${ignored_attributes}    mode    operations
    END

    ${comparison_result}=    Compare Dictionaries Ignoring Keys
    ${comparison_result}=    Compare Context Source Registrations Ignoring Keys
    ...    ${expected_context_source_registrations_payload}
    ...    ${response_body}
    ...    ${ignored_attributes}
@@ -571,6 +571,14 @@ Check Created Resource Set To

    Check Resource Set To    ${created_resource}    ${response_body}    ${ignored_keys}

Check Created Context Source Registration Set To
    [Arguments]    ${created_registration}    ${response_body}    ${ignored_keys}=${None}
    ${comparison_result}=    Compare Context Source Registrations Ignoring Keys
    ...    ${created_registration}
    ...    ${response_body}
    ...    ${ignored_keys}
    Should Be Empty    ${comparison_result}    msg=${comparison_result.pretty()}

Check Created Resources Set To
    [Arguments]    ${expected_resources}    ${response_body}    ${ignored_keys}=${None}

Loading