From 9c3eefa3d9d244ea561fe3ffd4b31bd4394b2b2f Mon Sep 17 00:00:00 2001 From: robertomier Date: Wed, 29 May 2024 10:02:13 +0200 Subject: [PATCH] NOV-244340: expression for selecting in an array for a context storage (#388) * chore: base code for selecting in an array using expresions like a.[key='value'].b * fix: previous unitary tests with the new code * test: add positive unitary tests for the new feature * test: add negative unitary tests * doc: updated methods docstring * doc: updated changelog * fix: flake8 format for unitary testing * fix: flake8 format for dataset module * chore: adapt code to codeclimate bot stupidity * test: add unitary test for invalid list structure --- CHANGELOG.rst | 1 + .../utils/test_dataset_map_param_context.py | 313 +++++++++++++++++- toolium/utils/dataset.py | 51 ++- 3 files changed, 357 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2a0c43be..17879c6c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ v3.1.5 *Release date: In development* - Fix `export_poeditor_project` method allowing empty export response +- Add key=value expressions for selecting elements in the context storage v3.1.4 ------ diff --git a/toolium/test/utils/test_dataset_map_param_context.py b/toolium/test/utils/test_dataset_map_param_context.py index 2fc1a517..39873535 100644 --- a/toolium/test/utils/test_dataset_map_param_context.py +++ b/toolium/test/utils/test_dataset_map_param_context.py @@ -329,7 +329,7 @@ class Context(object): with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.text]") - assert "the index 'text' must be a numeric index" == str(excinfo.value) + assert "the expression 'text' was not able to select an element in the list" == str(excinfo.value) def test_a_context_param_list_correct_index(): @@ -411,11 +411,11 @@ class Context(object): with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.prueba.id]") - assert "the index 'prueba' must be a numeric index" == str(excinfo.value) + assert "the expression 'prueba' was not able to select an element in the list" == str(excinfo.value) with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.'36'.id]") - assert "the index ''36'' must be a numeric index" == str(excinfo.value) + assert "the expression ''36'' was not able to select an element in the list" == str(excinfo.value) def test_a_context_param_class_no_numeric_index(): @@ -441,8 +441,311 @@ def __init__(self): print(context) with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.prueba.id]") - assert "the index 'prueba' must be a numeric index" == str(excinfo.value) + assert "the expression 'prueba' was not able to select an element in the list" == str(excinfo.value) with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.'36'.id]") - assert "the index ''36'' must be a numeric index" == str(excinfo.value) + assert "the expression ''36'' was not able to select an element in the list" == str(excinfo.value) + + +def test_a_context_param_list_correct_select_expression(): + """ + Verification of a list with a correct select expression as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param("[CONTEXT:list.cmsScrollableActions.id=ask-for-qa.text]") + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_value_single_quotes(): + """ + Verification of a list with a correct select expression having single quotes for value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param("[CONTEXT:list.cmsScrollableActions.id='ask-for-qa'.text]") + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_value_double_quotes(): + """ + Verification of a list with a correct select expression having double quotes for value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions.id="ask-for-qa".text]') + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_key_single_quotes(): + """ + Verification of a list with a correct select expression having single quotes for the value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param("[CONTEXT:list.cmsScrollableActions.'id'=ask-for-qa.text]") + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_key_double_quotes(): + """ + Verification of a list with a correct select expression having double quotes for the key as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions."id"=ask-for-qa.text]') + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_key_and_value_with_quotes(): + """ + Verification of a list with a correct select expression having quotes for both key and value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions."id"="ask-for-qa".text]') + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_blanks(): + """ + Verification of a list with a correct select expression with blanks in the text to search for as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions.text="QA no duplica".id]') + == "ask-for-qa" + ) + + +def test_a_context_param_list_correct_select_expression_finds_nothing(): + """ + Verification of a list with a correct select expression which obtains no result as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.id='not-existing-id'.text]") + assert ( + "the expression 'id='not-existing-id'' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_correct_select_expression_with_empty_value_finds_nothing(): + """ + Verification of a list with a correct select expression with empty value which obtains no result as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.id=''.text]") + assert ( + "the expression 'id=''' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_correct_select_expression_with_empty_value_hits_value(): + """ + Verification of a list with a correct select expression with empty value which obtains no result as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert map_param("[CONTEXT:list.cmsScrollableActions.id=''.text]") == "QA duplica" + + +def test_a_context_param_list_invalid_select_expression(): + """ + Verification of a list with an invalid select expression as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.invalidexpression.text]") + assert ( + "the expression 'invalidexpression' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_invalid_select_expression_having_empty_key(): + """ + Verification of a list with a invalid select expression having empty key as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.='not-existing-id'.text]") + assert ( + "the expression '='not-existing-id'' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_invalid_structure_for_valid_select(): + """ + Verification of a list with a invalid structure for a valid select as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": {"id": "ask-for-duplicate", "text": "QA duplica"}, + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.id=ask-for-duplicate.text]") + assert ( + "'id=ask-for-duplicate' key not found in {'id': 'ask-for-duplicate', 'text': 'QA duplica'} value in context" + == str(excinfo.value) + ) diff --git a/toolium/utils/dataset.py b/toolium/utils/dataset.py index fa09df11..2750f81c 100644 --- a/toolium/utils/dataset.py +++ b/toolium/utils/dataset.py @@ -578,8 +578,19 @@ def get_value_from_context(param, context): storages or the context object itself. In a dotted case, "last_request.result" is searched as a "last_request" key in the context storages or as a property of the context object whose name is last_request. In both cases, when found, "result" is considered (and resolved) as a property or a key into the returned value. + If the resolved element at one of the tokens is a list, then the next token (if present) is used as the index - to select one of its elements, e.g. "list.1" returns the second element of the list "list". + to select one of its elements in case it is a number, e.g. "list.1" returns the second element of the list "list". + + If the resolved element at one of the tokens is a list and the next token is a key=value expression, then the + element in the list that matches the key=value expression is selected, e.g. "list.key=value" returns the element + in the list "list" that has the value for key attribute. So, for example, if the list is: + [ + {"key": "value1", "attr": "attr1"}, + {"key": "value2", "attr": "attr2"} + ] + then "list.key=value2" returns the second element in the list. Also does "list.'key'='value2'", + "list.'key'=\"value2\"", "list.\"key\"='value2'" or "list.\"key\"=\"value2\"". There is not limit in the nested levels of dotted tokens, so a key like a.b.c.d will be tried to be resolved as: @@ -596,19 +607,53 @@ def get_value_from_context(param, context): msg = None for part in parts[1:]: + # the regular case is having a key in a dict if isinstance(value, dict) and part in value: value = value[part] + # evaluate if in an array, access is requested by index elif isinstance(value, list) and part.isdigit() and int(part) < len(value): value = value[int(part)] + # or by a key=value expression + elif isinstance(value, list) and (element := _select_element_in_list(value, part)): + value = element + # look for an attribute in an object elif hasattr(value, part): value = getattr(value, part) else: + # raise an exception if not possible to resolve the current part against the current value msg = _get_value_context_error_msg(value, part) logger.error(msg) - raise Exception(msg) + raise ValueError(msg) return value +def _select_element_in_list(the_list, expression): + """ + Select an element in the list that matches the key=value expression. + + :param the_list: list of dictionaries + :param expression: key=value expression + :return: the element in the list that matches the key=value expression + """ + if not expression: + return None + tokens = expression.split('=') + if len(tokens) != 2 or len(tokens[0]) == 0: + return None + + def _trim_quotes(value): + if len(value) >= 2 and value[0] == value[-1] and value[0] in ['"', "'"]: + return value[1:-1] + return value + + key = _trim_quotes(tokens[0]) + value = _trim_quotes(tokens[1]) + for idx, item in enumerate(the_list): + if key in item and item[key] == value: + return the_list[idx] + return None + + def _get_value_context_error_msg(value, part): """ Returns an appropriate error message when an error occurs resolving a CONTEXT reference. @@ -623,7 +668,7 @@ def _get_value_context_error_msg(value, part): if part.isdigit(): return f"Invalid index '{part}', list size is '{len(value)}'. {part} >= {len(value)}." else: - return f"the index '{part}' must be a numeric index" + return f"the expression '{part}' was not able to select an element in the list" else: return f"'{part}' attribute not found in {type(value).__name__} class in context"