From 576a9df07ce28d3f270dbb86d50b01bda6001378 Mon Sep 17 00:00:00 2001 From: Jordi Pique Date: Sat, 2 Sep 2023 17:38:22 +0100 Subject: [PATCH] Ability to parse string expressions When we define the conditions for a webhook, now we can use plain string expressions like `.containers.0.maxCPUs >= .containers.0.minCPUs + 2`. This feature will be available in the next schema version, so the behaviour of the "alpha1v1" schema keeps being the same. --- .../config_parser/entrypoint.py | 39 +++- .../config_parser/expr_parser.py | 198 ++++++++++++++++++ .../config_parser/operator_parser.py | 46 ++-- generic_k8s_webhook/operators.py | 142 +++++++++---- poetry.lock | 52 ++++- pyproject.toml | 1 + tests/operators_test.py | 76 ++++++- 7 files changed, 479 insertions(+), 75 deletions(-) create mode 100644 generic_k8s_webhook/config_parser/expr_parser.py diff --git a/generic_k8s_webhook/config_parser/entrypoint.py b/generic_k8s_webhook/config_parser/entrypoint.py index 08b3478..9a6dc1c 100644 --- a/generic_k8s_webhook/config_parser/entrypoint.py +++ b/generic_k8s_webhook/config_parser/entrypoint.py @@ -5,6 +5,7 @@ from generic_k8s_webhook.config_parser.action_parser import ActionParserV1 from generic_k8s_webhook.config_parser.jsonpatch_parser import JsonPatchParserV1 from generic_k8s_webhook.config_parser.webhook_parser import WebhookParserV1 +import generic_k8s_webhook.config_parser.expr_parser as expr_parser from generic_k8s_webhook.webhook import Webhook @@ -57,18 +58,20 @@ def __init__(self, raw_config: dict) -> None: # Select the correct parsing method according to the api version, since different api versions # expect different schemas if self.apiversion == "v1alpha1": - self.list_webhook_config = self._parse_alpha1v1(raw_list_webhook_config) + self.list_webhook_config = self._parse_v1alpha1(raw_list_webhook_config) + elif self.apiversion == "v1beta1": + self.list_webhook_config = self._parse_v1beta1(raw_list_webhook_config) else: raise ValueError(f"The api version {self.apiversion} is not supported") if len(raw_config) > 0: raise ValueError(f"Invalid fields at the manifest level: {raw_config}") - def _parse_alpha1v1(self, raw_list_webhook_config: dict) -> list[Webhook]: + def _parse_v1alpha1(self, raw_list_webhook_config: dict) -> list[Webhook]: webhook_parser = WebhookParserV1( action_parser=ActionParserV1( meta_op_parser=op_parser.MetaOperatorParser( - [ + list_op_parser_classes=[ op_parser.AndParser, op_parser.OrParser, op_parser.EqualParser, @@ -79,7 +82,35 @@ def _parse_alpha1v1(self, raw_list_webhook_config: dict) -> list[Webhook]: op_parser.ContainParser, op_parser.ConstParser, op_parser.GetValueParser, - ] + ], + raw_str_parser=expr_parser.RawStringParserNotImplemented(), + ), + json_patch_parser=JsonPatchParserV1(), + ) + ) + list_webhook_config = [ + webhook_parser.parse(raw_webhook_config, f"webhooks.{i}") + for i, raw_webhook_config in enumerate(raw_list_webhook_config) + ] + return list_webhook_config + + def _parse_v1beta1(self, raw_list_webhook_config: dict) -> list[Webhook]: + webhook_parser = WebhookParserV1( + action_parser=ActionParserV1( + meta_op_parser=op_parser.MetaOperatorParser( + list_op_parser_classes=[ + op_parser.AndParser, + op_parser.OrParser, + op_parser.EqualParser, + op_parser.SumParser, + op_parser.NotParser, + op_parser.ListParser, + op_parser.ForEachParser, + op_parser.ContainParser, + op_parser.ConstParser, + op_parser.GetValueParser, + ], + raw_str_parser=expr_parser.RawStringParserV1(), ), json_patch_parser=JsonPatchParserV1(), ) diff --git a/generic_k8s_webhook/config_parser/expr_parser.py b/generic_k8s_webhook/config_parser/expr_parser.py new file mode 100644 index 0000000..85c0f16 --- /dev/null +++ b/generic_k8s_webhook/config_parser/expr_parser.py @@ -0,0 +1,198 @@ +import abc +import ast +from lark import Lark, Transformer + +import generic_k8s_webhook.operators as op +from generic_k8s_webhook import utils + + +GRAMMAR_V1 = r""" + ?start: expr + + ?expr: or + + ?or: and + | or "||" and -> orr + + ?and: comp + | and "&&" comp -> andd + + ?comp: sum + | sum "==" sum -> eq + | sum "!=" sum -> ne + | sum "<=" sum -> le + | sum ">=" sum -> ge + | sum "<" sum -> lt + | sum ">" sum -> gt + + ?sum: product + | sum "+" product -> add + | sum "-" product -> sub + + ?product: atom + | product "*" atom -> mul + | product "/" atom -> div + + ?atom: SIGNED_NUMBER -> number + | ESCAPED_STRING -> const_string + | REF -> ref + | BOOL -> boolean + | "(" expr ")" + + BOOL: "true" | "false" + REF: "$"? ("."(CNAME|"*"|INT))+ + + %import common.CNAME + %import common.SIGNED_NUMBER + %import common.ESCAPED_STRING + %import common.WS_INLINE + %import common.INT + + %ignore WS_INLINE +""" + + +class MyTransformerV1(Transformer): + def orr(self, items): + return op.Or(op.List(items)) + + def andd(self, items): + return op.And(op.List(items)) + + def eq(self, items): + return op.Equal(op.List(items)) + + def ne(self, items): + return op.NotEqual(op.List(items)) + + def le(self, items): + return op.LessOrEqual(op.List(items)) + + def ge(self, items): + return op.GreaterOrEqual(op.List(items)) + + def lt(self, items): + return op.LessThan(op.List(items)) + + def gt(self, items): + return op.GreaterThan(op.List(items)) + + def add(self, items): + return op.Sum(op.List(items)) + + def sub(self, items): + return op.Sub(op.List(items)) + + def mul(self, items): + return op.Mul(op.List(items)) + + def div(self, items): + return op.Div(op.List(items)) + + def number(self, items): + (elem,) = items + try: + elem_number = int(elem) + except ValueError: + elem_number = float(elem) + return op.Const(elem_number) + + def const_string(self, items): + (elem,) = items + # This evaluates the double-quoted string, so the initial and ending double quotes disappear + # and any escaped char is also converted. For example, \" -> " + elem_str = ast.literal_eval(elem) + return op.Const(elem_str) + + def ref(self, items): + (elem,) = items + return parse_ref(elem) + + def boolean(self, items): + (elem,) = items + elem_bool = True if elem == "true" else False + return op.Const(elem_bool) + + +def parse_ref(ref: str) -> op.GetValue: + """Parses a string that is a reference to some element within a json payload + and returns a GetValue object. + + Args: + ref (str): The reference to a field in a json payload + """ + # Convert, for example, `.foo.bar` into ["foo", "bar"] + path = utils.convert_dot_string_path_to_list(ref) + + # Get the id of the context that it will use + if path[0] == "": + context_id = -1 + elif path[0] == "$": + context_id = 0 + else: + raise ValueError(f"Invalid {path[0]} in {ref}") + return op.GetValue(path[1:], context_id) + + +class IRawStringParser(abc.ABC): + def __init__(self) -> None: + self.parser = Lark(self.get_grammar()) + self.transformer = self.get_transformer() + + def parse(self, raw_string: str) -> op.Operator: + tree = self.parser.parse(raw_string) + print(tree.pretty()) # debug mode + operator = self.transformer.transform(tree) + return operator + + @classmethod + @abc.abstractmethod + def get_grammar(cls) -> str: + pass + + @classmethod + @abc.abstractmethod + def get_transformer(cls) -> Transformer: + pass + + +class RawStringParserNotImplemented(IRawStringParser): + def __init__(self) -> None: # pylint: disable=super-init-not-called + # Empty method + pass + + def parse(self, raw_string: str) -> op.Operator: + return NotImplementedError("Parsing string expressions is not supported") + + @classmethod + def get_grammar(cls) -> str: + return "" + + @classmethod + def get_transformer(cls) -> Transformer: + return Transformer() + + +class RawStringParserV1(IRawStringParser): + @classmethod + def get_grammar(cls) -> str: + return GRAMMAR_V1 + + @classmethod + def get_transformer(cls) -> Transformer: + return MyTransformerV1() + + +def main(): + parser = Lark(GRAMMAR_V1) + # print(parser.parse('.key != "some string"').pretty()) + tree = parser.parse('"true" != "false"') + print(tree.pretty()) + trans = MyTransformerV1() + new_op = trans.transform(tree) + print(new_op) + print(new_op.get_value([])) + + +if __name__ == "__main__": + main() diff --git a/generic_k8s_webhook/config_parser/operator_parser.py b/generic_k8s_webhook/config_parser/operator_parser.py index 6924d03..5633be3 100644 --- a/generic_k8s_webhook/config_parser/operator_parser.py +++ b/generic_k8s_webhook/config_parser/operator_parser.py @@ -3,10 +3,11 @@ from generic_k8s_webhook import operators, utils from generic_k8s_webhook.config_parser.common import ParsingException +import generic_k8s_webhook.config_parser.expr_parser as expr_parser class MetaOperatorParser: - def __init__(self, list_op_parser_classes: list[type]) -> None: + def __init__(self, list_op_parser_classes: list[type], raw_str_parser: expr_parser.IRawStringParser) -> None: self.dict_op_parser = {} for op_parser_class in list_op_parser_classes: # Make sure that op_parser_class is a proper "OperatorParser" derived class @@ -22,7 +23,24 @@ def __init__(self, list_op_parser_classes: list[type]) -> None: raise RuntimeError(f"Duplicated operator parser {op_parser.get_name()}") self.dict_op_parser[op_parser.get_name()] = op_parser - def parse(self, op_spec: dict, path_op: str) -> operators.Operator: + self.raw_str_parser = raw_str_parser + + def parse(self, op_spec: dict | str, path_op: str) -> operators.Operator: + if isinstance(op_spec, dict): + return self._parse_dict(op_spec, path_op) + if isinstance(op_spec, str): + return self._parse_str(op_spec, path_op) + raise RuntimeError(f"Cannot parse the type {type(op_spec)}. It must be dict or str") + + def _parse_dict(self, op_spec: dict, path_op: str) -> operators.Operator: + """It's used to parse a structured operator. Example: + + ```yaml + sum: + - const: 4 + - const: 5 + ``` + """ if len(op_spec) != 1: raise ValueError(f"Expected exactly one key under {path_op}") op_name, op_spec = op_spec.popitem() @@ -33,6 +51,18 @@ def parse(self, op_spec: dict, path_op: str) -> operators.Operator: return op + def _parse_str(self, op_spec: str, path_op: str) -> operators.Operator: + """It's used to parse an unstructured operator. Example: + + ```yaml + "4 + 5" + ``` + """ + try: + return self.raw_str_parser.parse(op_spec) + except Exception as e: + raise ParsingException(f"Error when parsing {path_op}") from e + class OperatorParser(abc.ABC): def __init__(self, meta_op_parser: MetaOperatorParser) -> None: @@ -205,17 +235,7 @@ def get_name(cls) -> str: def parse(self, op_inputs: str, path_op: str) -> operators.GetValue: if not isinstance(op_inputs, str): raise ValueError(f"Expected to find str but got {op_inputs} in {path_op}") - path = utils.convert_dot_string_path_to_list(op_inputs) - - # Get the id of the context that it will use - if path[0] == "": - context_id = -1 - elif path[0] == "$": - context_id = 0 - else: - raise ValueError(f"Invalid {path[0]} in {path_op}") - try: - return operators.GetValue(path, context_id) + return expr_parser.parse_ref(op_inputs) except TypeError as e: raise ParsingException(f"Error when parsing {path_op}") from e diff --git a/generic_k8s_webhook/operators.py b/generic_k8s_webhook/operators.py index 9253b79..6d226f3 100644 --- a/generic_k8s_webhook/operators.py +++ b/generic_k8s_webhook/operators.py @@ -37,17 +37,29 @@ def __init__(self, args: Operator) -> None: # A list[None] for the args.return_type means that we can get any type, so let's give it a try if ( self.args.return_type() is not None - and self.input_type() != list[None] and self.args.return_type() != list[None] + and self.input_type() is not None + and self.input_type() != list[None] ): - # The return type of the arguments must be a list - if get_origin(self.args.return_type()) != list: - raise TypeError(f"We expect a list as input but got {self.args.return_type()}") - # Compare the subscripted types - nested_input_type = get_args(self.input_type()) - nested_args_return_type = get_args(self.args.return_type()) - if not issubclass(nested_args_return_type[0], nested_input_type[0]): - raise TypeError(f"We expect {self.input_type()} as input but got {self.args.return_type()}") + # Compare the origin types. The origin or `list[int]` is `list` + origin_input_type = get_origin(self.input_type()) + origin_args_ret_type = get_origin(self.args.return_type()) + if origin_input_type != origin_args_ret_type: + raise TypeError(f"We expect a {self.input_type()} as input but got {self.args.return_type()}") + + # Compare the subscripted types. The subscripted type of `list[int, float]` are `int, float` + list_nested_input_type = get_args(self.input_type()) + list_nested_args_return_types = get_args(self.args.return_type()) + # Check that all the subscripted types of the arguments match at least one of + # the subscripted types that this operator expects as input + for nested_args_ret_type in list_nested_args_return_types: + type_match = False + for nested_input_type in list_nested_input_type: + if issubclass(nested_args_ret_type, nested_input_type): + type_match = True + break + if not type_match: + raise TypeError(f"We expect {self.input_type()} as input but got {self.args.return_type()}") def get_value(self, contexts: list) -> Any: elements = self.args.get_value(contexts) @@ -63,7 +75,7 @@ def get_value(self, contexts: list) -> Any: raise TypeError(f"Expected list but got {elements}") if len(elements) == 0: - return self._no_op_result() + return self._zero_args_result() elem = elements[0] # If we have a single element, try to cast it to the type the operation @@ -80,49 +92,75 @@ def get_value(self, contexts: list) -> Any: def _op(self, lhs, rhs): pass - @abc.abstractmethod - def _no_op_result(self): - pass + def _zero_args_result(self): + """The value returned when there are 0 arguments in the operator""" + return self.return_type().__call__() # pylint: disable=unnecessary-dunder-call -class And(BinaryOp): +class BoolOp(BinaryOp): def input_type(self) -> type | None: return list[bool] def return_type(self) -> type | None: return bool + +class And(BoolOp): def _op(self, lhs, rhs): return lhs and rhs - def _no_op_result(self): + def _zero_args_result(self): + return True + + +class Or(BoolOp): + def _op(self, lhs, rhs): + return lhs or rhs + + def _zero_args_result(self): return True -class Or(BinaryOp): +class ArithOp(BinaryOp): def input_type(self) -> type | None: - return list[bool] + return list[Number] def return_type(self) -> type | None: - return bool + return Number + + def _zero_args_result(self) -> Number: + return 0 + +class Sum(ArithOp): def _op(self, lhs, rhs): - return lhs or rhs + return lhs + rhs - def _no_op_result(self): - return True + +class Sub(ArithOp): + def _op(self, lhs, rhs): + return lhs - rhs -class Equal(BinaryOp): +class Mul(ArithOp): + def _op(self, lhs, rhs): + return lhs * rhs + + +class Div(ArithOp): + def _op(self, lhs, rhs): + return lhs / rhs + + +class Comp(BinaryOp): def get_value(self, contexts: list) -> Any: list_arg_values = self.args.get_value(contexts) if len(list_arg_values) < 2: return True - elem_golden = list_arg_values[0] - for elem in list_arg_values[1:]: - if elem != elem_golden: - return False - return True + elif len(list_arg_values) == 2: + return self._op(list_arg_values[0], list_arg_values[1]) + else: + raise ValueError("A comparison cannot have more than 2 operands") def input_type(self) -> type | None: return list[None] @@ -130,25 +168,35 @@ def input_type(self) -> type | None: def return_type(self) -> type | None: return bool + +class Equal(Comp): def _op(self, lhs, rhs): - pass # unused + return lhs == rhs - def _no_op_result(self): - pass # unused +class NotEqual(Comp): + def _op(self, lhs, rhs): + return lhs != rhs -class Sum(BinaryOp): - def input_type(self) -> type | None: - return list[Number] - def return_type(self) -> type | None: - return Number +class LessOrEqual(Comp): + def _op(self, lhs, rhs): + return lhs <= rhs + +class GreaterOrEqual(Comp): def _op(self, lhs, rhs): - return lhs + rhs + return lhs >= rhs - def _no_op_result(self): - return 0 + +class LessThan(Comp): + def _op(self, lhs, rhs): + return lhs < rhs + + +class GreaterThan(Comp): + def _op(self, lhs, rhs): + return lhs > rhs class UnaryOp(Operator): @@ -184,12 +232,12 @@ def __init__(self, list_op: list[Operator]) -> None: # Get all the different return types, but ignore None, since this means # that the return type is not defined at "compile" time (depens on the input data) types_in_list = set(op.return_type() for op in self.list_op if op.return_type() is not None) - if len(types_in_list) > 1: - raise TypeError("Non homogeneous return type") if len(types_in_list) == 0: - self.item_type = None + self.item_types = list[None] else: - self.item_type = types_in_list.pop() + # For example, if `types_in_list={int, float}`, then + # `self.item_types=list[int, float]` + self.item_types = list[*list(types_in_list)] def get_value(self, contexts: list): return [op.get_value(contexts) for op in self.list_op] @@ -198,7 +246,7 @@ def input_type(self) -> type | None: return None def return_type(self) -> type | None: - return list[self.item_type] + return self.item_types class ForEach(Operator): @@ -264,7 +312,7 @@ def __init__(self, path: list[str], context_id: int) -> None: def get_value(self, contexts: list): context = contexts[self.context_id] - return self._get_value_from_json(context, self.path[1:]) + return self._get_value_from_json(context, self.path) def _get_value_from_json(self, data: Union[list, dict], path: list): if len(path) == 0 or path[0] == "": @@ -272,13 +320,15 @@ def _get_value_from_json(self, data: Union[list, dict], path: list): if isinstance(data, dict): key = path[0] + if key in data: + return self._get_value_from_json(data[key], path[1:]) elif isinstance(data, list): key = int(path[0]) + if 0 <= key < len(data): + return self._get_value_from_json(data[key], path[1:]) else: raise RuntimeError(f"Expected list or dict, but got {data}") - if key in data: - return self._get_value_from_json(data[key], path[1:]) return None def input_type(self) -> type | None: diff --git a/poetry.lock b/poetry.lock index b5be050..c041477 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. [[package]] name = "astroid" version = "2.15.5" description = "An abstract syntax tree for Python with inference support." +category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -23,6 +24,7 @@ wrapt = [ name = "black" version = "23.3.0" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -71,6 +73,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -82,6 +85,7 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -166,6 +170,7 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -180,6 +185,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -191,6 +197,7 @@ files = [ name = "dill" version = "0.3.6" description = "serialize all of python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -205,6 +212,7 @@ graph = ["objgraph (>=1.7.2)"] name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -219,6 +227,7 @@ test = ["pytest (>=6)"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -230,6 +239,7 @@ files = [ name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -241,6 +251,7 @@ files = [ name = "isort" version = "5.12.0" description = "A Python utility / library to sort Python imports." +category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -258,6 +269,7 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "jsonpatch" version = "1.33" description = "Apply JSON-Patches (RFC 6902)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" files = [ @@ -271,16 +283,36 @@ jsonpointer = ">=1.9" name = "jsonpointer" version = "2.4" description = "Identify specific nodes in a JSON document (RFC 6901)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" files = [ {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, ] +[[package]] +name = "lark" +version = "1.1.7" +description = "a modern parsing library" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "lark-1.1.7-py3-none-any.whl", hash = "sha256:9e5dc5bbf93fa1840083707285262514a0ef8a6613874af7ea1cec60468d6e92"}, + {file = "lark-1.1.7.tar.gz", hash = "sha256:be7437bf1f37ab08b355f29ff2571d77d777113d0a8c4352b0c513dced6c5a1e"}, +] + +[package.extras] +atomic-cache = ["atomicwrites"] +interegular = ["interegular (>=0.3.1,<0.4.0)"] +nearley = ["js2py"] +regex = ["regex"] + [[package]] name = "lazy-object-proxy" version = "1.9.0" description = "A fast and thorough lazy object proxy." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -326,6 +358,7 @@ files = [ name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -337,6 +370,7 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -348,6 +382,7 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -359,6 +394,7 @@ files = [ name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -370,6 +406,7 @@ files = [ name = "platformdirs" version = "3.8.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -385,6 +422,7 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -400,6 +438,7 @@ testing = ["pytest", "pytest-benchmark"] name = "pylint" version = "2.17.4" description = "python code static checker" +category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -428,6 +467,7 @@ testutils = ["gitpython (>3)"] name = "pytest" version = "7.3.2" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -450,6 +490,7 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-timeout" version = "2.1.0" description = "pytest plugin to abort hanging tests" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -464,6 +505,7 @@ pytest = ">=5.0.0" name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -513,6 +555,7 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -534,6 +577,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -545,6 +589,7 @@ files = [ name = "tomlkit" version = "0.11.8" description = "Style preserving TOML library" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -556,6 +601,7 @@ files = [ name = "typing-extensions" version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -567,6 +613,7 @@ files = [ name = "urllib3" version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -584,6 +631,7 @@ zstd = ["zstandard (>=0.18.0)"] name = "wrapt" version = "1.15.0" description = "Module for decorators, wrappers and monkey patching." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" files = [ @@ -667,4 +715,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "7064b99a6dfed070054579f4494df670bb532d6f9236928f006a2f3912a391a9" +content-hash = "3cdaed98fe9f7d3cfab5f4eaf71b02895054fd900033f6c2085a33f33eb3e55d" diff --git a/pyproject.toml b/pyproject.toml index 3c4df98..f087e6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ python = "^3.10" PyYAML = "^6.0" jsonpatch = "^1.33" requests = "^2.31.0" +lark = "^1.1.7" [tool.poetry.scripts] generic_k8s_webhook = "generic_k8s_webhook.main:main" diff --git a/tests/operators_test.py b/tests/operators_test.py index 4da8bdb..8acd975 100644 --- a/tests/operators_test.py +++ b/tests/operators_test.py @@ -1,12 +1,17 @@ import pytest import generic_k8s_webhook.config_parser.operator_parser as op_parser +import generic_k8s_webhook.config_parser.expr_parser as expr_parser def _exec_test( - list_parsers: list[op_parser.OperatorParser], raw_op: dict, contexts: list[dict], expected_result + list_parsers: list[op_parser.OperatorParser], + raw_str_parser: expr_parser.IRawStringParser, + raw_op: dict, + contexts: list[dict], + expected_result, ) -> None: - meta_op_parser = op_parser.MetaOperatorParser(list_parsers) + meta_op_parser = op_parser.MetaOperatorParser(list_parsers, raw_str_parser) op = meta_op_parser.parse(raw_op, "") result = op.get_value(contexts) assert result == expected_result @@ -45,7 +50,7 @@ def _exec_test( ], ) def test_and(raw_op, expected_result): - _exec_test([op_parser.AndParser, op_parser.ConstParser], raw_op, [], expected_result) + _exec_test([op_parser.AndParser, op_parser.ConstParser], None, raw_op, [], expected_result) @pytest.mark.parametrize( @@ -81,7 +86,7 @@ def test_and(raw_op, expected_result): ], ) def test_or(raw_op, expected_result): - _exec_test([op_parser.OrParser, op_parser.ConstParser], raw_op, [], expected_result) + _exec_test([op_parser.OrParser, op_parser.ConstParser], None, raw_op, [], expected_result) @pytest.mark.parametrize( @@ -91,7 +96,7 @@ def test_or(raw_op, expected_result): ], ) def test_not(raw_op, expected_result): - _exec_test([op_parser.NotParser, op_parser.ConstParser], raw_op, [], expected_result) + _exec_test([op_parser.NotParser, op_parser.ConstParser], None, raw_op, [], expected_result) @pytest.mark.parametrize( @@ -127,7 +132,7 @@ def test_not(raw_op, expected_result): ], ) def test_equal(raw_op, expected_result): - _exec_test([op_parser.EqualParser, op_parser.ConstParser], raw_op, [], expected_result) + _exec_test([op_parser.EqualParser, op_parser.ConstParser], None, raw_op, [], expected_result) @pytest.mark.parametrize( @@ -155,7 +160,7 @@ def test_equal(raw_op, expected_result): ], ) def test_sum(raw_op, expected_result): - _exec_test([op_parser.SumParser, op_parser.ConstParser], raw_op, [], expected_result) + _exec_test([op_parser.SumParser, op_parser.ConstParser], None, raw_op, [], expected_result) @pytest.mark.parametrize( @@ -182,7 +187,7 @@ def test_sum(raw_op, expected_result): ], ) def test_getvalue(name, raw_op, contexts, expected_result): - _exec_test([op_parser.GetValueParser], raw_op, contexts, expected_result) + _exec_test([op_parser.GetValueParser], None, raw_op, contexts, expected_result) @pytest.mark.parametrize( @@ -229,7 +234,7 @@ def test_getvalue(name, raw_op, contexts, expected_result): ) def test_foreach(name, raw_op, contexts, expected_result): parsers = [op_parser.ForEachParser, op_parser.GetValueParser, op_parser.SumParser, op_parser.ConstParser] - _exec_test(parsers, raw_op, contexts, expected_result) + _exec_test(parsers, None, raw_op, contexts, expected_result) @pytest.mark.parametrize( @@ -251,4 +256,55 @@ def test_foreach(name, raw_op, contexts, expected_result): ) def test_contain(name, raw_op, contexts, expected_result): parsers = [op_parser.ContainParser, op_parser.GetValueParser, op_parser.ConstParser] - _exec_test(parsers, raw_op, contexts, expected_result) + _exec_test(parsers, None, raw_op, contexts, expected_result) + + +@pytest.mark.parametrize( + ("name", "raw_op", "contexts", "expected_result"), + [ + ( + "Arithmetic operations", + "2 * (3 + 4 / 2) - 1", + [], + 9, + ), + ( + "Arithmetic operations", + "2*(3+4/2)-1", + [], + 9, + ), + ( + "Arithmetic operations", + "8/4/2", + [], + 1, + ), + ( + "Boolean operations and comp", + "1 == 1 && 1 != 0 && 0 <= 0 && 0 < 1 && 1 > 0 && 1 >= 1 && true", + [], + True, + ), + ( + "Boolean operations and comp", + "1 != 1 || 1 == 0 || 0 < 0 || 0 >= 1 || 1 <= 0 || 1 < 1 || false", + [], + False, + ), + ( + "String comp", + '"foo" == "foo" && "foo" != "bar"', + [], + True, + ), + ( + "Reference", + ".containers.0.maxCPU + 1 == .containers.1.maxCPU", + [{"containers": [{"maxCPU": 1}, {"maxCPU": 2}]}], + True, + ), + ], +) +def test_raw_str_expr(name, raw_op, contexts, expected_result): + _exec_test([], expr_parser.RawStringParserV1(), raw_op, contexts, expected_result)