diff --git a/kernelci/cli/__init__.py b/kernelci/cli/__init__.py index bf11d0cf86..5f6288ba5e 100644 --- a/kernelci/cli/__init__.py +++ b/kernelci/cli/__init__.py @@ -13,6 +13,9 @@ from TOML settings. """ +import re +import typing + import click import kernelci.settings @@ -129,3 +132,48 @@ def group(self, *args, cls=None, **kwargs): def kci(ctx, settings): """Entry point for the kci command line tool""" ctx.obj = CommandSettings(settings) + + +def split_attributes(attributes: typing.List[str]): + """Split attributes into a dictionary. + + Split the attributes string into a dictionary using space as a delimiter + between key/value pairs and `=` between the key and the value. The API + operators are expected to be part of the key e.g. score__gte=100 to find + objects with a 'score' attribute of 100 or more. + + As a syntactic convenience, if the operator matches one of >, <, >=, <=, != + then the corresponding API operator '__gt', '__lt', '__gte', '__lte', + '__ne' is added to the key name automatically. + """ + operators = { + '>': '__gt', + '<': '__lt', + '>=': '__gte', + '<=': '__lte', + '!=': '__ne', + '=': '', + } + pattern = re.compile(r'^([.a-zA-Z0-9_-]+) *([<>!=]+) *(.*)') + + parsed = {} + for attribute in attributes: + match = pattern.match(attribute) + if not match: + raise click.ClickException(f"Invalid attribute: {attribute}") + name, operator, value = match.groups() + ex_op, ex_value = parsed.get(name, (None, None)) + if ex_value: + raise click.ClickException( + f"Conflicting values for {name}: \ + {name}{value}, {ex_op}{ex_value}" + ) + opstr = operators.get(operator) + if opstr is None: + raise click.ClickException(f"Invalid operator: {operator}") + parsed[name] = (opstr, value) + + return { + ''.join((key, opstr)): value + for key, (opstr, value) in parsed.items() + } diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c5be9f0d0..25ed93b973 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,8 @@ """Unit test for the KernelCI command line tools""" +import pytest + import click import kernelci.cli @@ -60,3 +62,46 @@ def cmd(foo, secrets): # pylint: disable=disallowed-name except SystemExit as exc: if exc.code != 0: raise exc + + +def test_split_valid_attributes(): + """Test the logic to split valid attribute with operators""" + attributes = [ + (['name=value'], {'name': 'value'}), + (['name>value'], {'name__gt': 'value'}), + (['name>=value'], {'name__gte': 'value'}), + (['name= value'], {'name__gte': 'value'}), + (['name>= value'], {'name__gte': 'value'}), + (['a=b', 'c=123', 'x3 = 1.2', 'abc >= 4', 'z != x[2]'], { + 'a': 'b', 'c': '123', 'x3': '1.2', 'abc__gte': '4', 'z__ne': 'x[2]' + }), + ] + for attrs, parsed in attributes: + print(attrs, parsed) + result = kernelci.cli.split_attributes(attrs) + assert result == parsed + + +def test_split_invalid_attributes(): + """Test the logic to split invalid attribute with operators""" + attributes = [ + ['key == something'], + ['key==else'], + ['key== else'], + ['x ==a'], + ['wr?ong = other'], + ['wrong| = other'], + ['foo=>bar'], + ['foo=bar'], + ['foo=!bar'], + ['a=1', 'a=again'], + ['key = 123', 'key >= 456'] + ] + for attrs in attributes: + with pytest.raises(click.ClickException): + kernelci.cli.split_attributes(attrs)