From ba15fdc23110d1992f2c438b9f4c6c25b3294f50 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 14 Nov 2023 13:06:34 +0000 Subject: [PATCH 1/5] Implement isEmpty for lists --- airspeed/operators.py | 1 + tests/test_templating.py | 3 +++ tests/test_templating.snapshot.json | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/airspeed/operators.py b/airspeed/operators.py index 53e53d3..934f864 100644 --- a/airspeed/operators.py +++ b/airspeed/operators.py @@ -44,6 +44,7 @@ def dict_to_string(obj: dict) -> str: "get": lambda self, index: self[index], "contains": lambda self, value: value in self, "add": lambda self, value: self.append(value), + "isEmpty": lambda self: len(self) == 0, }, dict: { "put": dict_put, diff --git a/tests/test_templating.py b/tests/test_templating.py index 3641b20..85acf12 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -774,6 +774,9 @@ def test_array_add_item(self, test_render): ) test_render(template) + def test_array_is_empty(self, test_render): + test_render("#set($foo = [1, 2, 3]) $foo.isEmpty()") + def test_string_length(self, test_render): test_render("#set($foo = 'foobar123') $foo.length()") diff --git a/tests/test_templating.snapshot.json b/tests/test_templating.snapshot.json index 5c19728..6972aa7 100644 --- a/tests/test_templating.snapshot.json +++ b/tests/test_templating.snapshot.json @@ -1040,5 +1040,12 @@ "render-result-1-cli": "$a.b.c['foo:bar']", "render-result-1": "baz" } + }, + "tests/test_templating.py::TestTemplating::test_array_is_empty": { + "recorded-date": "14-11-2023, 13:05:28", + "recorded-content": { + "render-result-1-cli": " false", + "render-result-1": " false" + } } } From 1dc84e49fed7e0d4a6d4c1085cf9b9176a1ed57a Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 14 Nov 2023 13:11:51 +0000 Subject: [PATCH 2/5] Implement string.indexOf --- airspeed/operators.py | 1 + tests/test_templating.py | 3 +++ tests/test_templating.snapshot.json | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/airspeed/operators.py b/airspeed/operators.py index 934f864..f005ffd 100644 --- a/airspeed/operators.py +++ b/airspeed/operators.py @@ -38,6 +38,7 @@ def dict_to_string(obj: dict) -> str: "replaceAll": lambda self, pattern, repl: re.sub(pattern, repl, self), "startsWith": lambda self, prefix: self.startswith(prefix), "contains": lambda self, value: value in self, + "indexOf": lambda self, ch: self.index(ch), }, list: { "size": lambda self: len(self), diff --git a/tests/test_templating.py b/tests/test_templating.py index 85acf12..91e5785 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -795,6 +795,9 @@ def test_string_contains_true(self, test_render): def test_string_contains_false(self, test_render): test_render("#set($foo = 'nofoobar123') #if($foo.contains('foo'))yes!#end") + def test_string_index_of(self, test_render): + test_render("#set($foo = 'something') $foo.indexOf('e')") + def test_dict_put_item(self, test_render): template = ( "#set( $ignore = $test_dict.put('k', 'new value') )" diff --git a/tests/test_templating.snapshot.json b/tests/test_templating.snapshot.json index 6972aa7..f0026e6 100644 --- a/tests/test_templating.snapshot.json +++ b/tests/test_templating.snapshot.json @@ -1047,5 +1047,12 @@ "render-result-1-cli": " false", "render-result-1": " false" } + }, + "tests/test_templating.py::TestTemplating::test_string_index_of": { + "recorded-date": "14-11-2023, 13:10:39", + "recorded-content": { + "render-result-1-cli": " 3", + "render-result-1": " 3" + } } } From 11524b501b1d919f43e9b1ac904dbea460a011b5 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 14 Nov 2023 15:46:57 +0000 Subject: [PATCH 3/5] Implement substring of strings --- airspeed/operators.py | 46 ++++++++++------------------- tests/test_templating.py | 3 ++ tests/test_templating.snapshot.json | 7 +++++ 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/airspeed/operators.py b/airspeed/operators.py index f005ffd..1987698 100644 --- a/airspeed/operators.py +++ b/airspeed/operators.py @@ -39,6 +39,7 @@ def dict_to_string(obj: dict) -> str: "startsWith": lambda self, prefix: self.startswith(prefix), "contains": lambda self, value: value in self, "indexOf": lambda self, ch: self.index(ch), + "substring": lambda self, start, end=None: self[start:end], }, list: { "size": lambda self: len(self), @@ -111,17 +112,13 @@ def __init__(self, element, exc_info): element.end, element.filename, ) - self.msg = ( - "Error in template '%s' at position " - "%d-%d in expression: %s\n%s: %s" - % ( - self.filename, - self.start, - self.end, - element.my_text(), - cause.__name__, - value, - ) + self.msg = "Error in template '%s' at position " "%d-%d in expression: %s\n%s: %s" % ( + self.filename, + self.start, + self.end, + element.my_text(), + cause.__name__, + value, ) def __str__(self): @@ -375,8 +372,7 @@ def evaluate(self, *args): class Text(_Element): PLAIN = re.compile( - r"((?:[^\\\$#]+|\\[\$#])+|\$[^!\{a-z0-9_]|\$$|#$" - r"|#[^\{\}a-zA-Z0-9#\*]+|\\.)(.*)$", + r"((?:[^\\\$#]+|\\[\$#])+|\$[^!\{a-z0-9_]|\$$|#$" r"|#[^\{\}a-zA-Z0-9#\*]+|\\.)(.*)$", re.S + re.I, ) ESCAPED_CHAR = re.compile(r"\\([\$#]\S+)") @@ -631,11 +627,7 @@ def _call_method(*args): if result is None: return None # TODO: an explicit 'not found' exception? if isinstance(result, _FunctionDefinition): - params = ( - self.parameters - and self.parameters.calculate(top_namespace, loader) - or [] - ) + params = self.parameters and self.parameters.calculate(top_namespace, loader) or [] stream = StoppableStream() result.execute_function(stream, top_namespace, params, loader) result_value = stream.getvalue() @@ -648,12 +640,8 @@ def _call_method(*args): elif self.index is not None: array_index = self.index.calculate(top_namespace, loader) # If list make sure index is an integer - if isinstance(result, list) and not isinstance( - array_index, six.integer_types - ): - raise ValueError( - "expected integer for array index, got '%s'" % (array_index) - ) + if isinstance(result, list) and not isinstance(array_index, six.integer_types): + raise ValueError("expected integer for array index, got '%s'" % (array_index)) try: result = result[array_index] except Exception: @@ -811,8 +799,7 @@ def evaluate(self, *args): class BinaryOperator(_Element): BINARY_OP = re.compile( - r"\s*(>=|<=|<|==|!=|>|%|\|\||&&|or|and|\+|\-|\*|\/|\%|gt|lt|ne|eq|ge" - r"|le|not)\s*(.*)$", + r"\s*(>=|<=|<|==|!=|>|%|\|\||&&|or|and|\+|\-|\*|\/|\%|gt|lt|ne|eq|ge" r"|le|not)\s*(.*)$", re.S, ) OPERATORS = { @@ -1048,9 +1035,7 @@ def evaluate_raw(self, stream, namespace, loader): # set($one.two().three = something) # yet class Assignment(_Element): - START = re.compile( - r"\s*\(\s*\$(\w*(?:\.[\w-]+|\[\"\$\w+\"\]*)*)\s*=\s*(.*)$", re.S + re.I - ) + START = re.compile(r"\s*\(\s*\$(\w*(?:\.[\w-]+|\[\"\$\w+\"\]*)*)\s*=\s*(.*)$", re.S + re.I) END = re.compile(r"\s*\)(?:[ \t]*\r?\n)?(.*)$", re.S + re.M) def parse(self): @@ -1304,8 +1289,7 @@ def evaluate_raw(self, stream, namespace, loader): iter(iterable) except TypeError: raise ValueError( - "value for $%s is not iterable in #foreach: %s" - % (self.loop_var_name, iterable) + "value for $%s is not iterable in #foreach: %s" % (self.loop_var_name, iterable) ) length = len(iterable) for item in iterable: diff --git a/tests/test_templating.py b/tests/test_templating.py index 91e5785..f345ffc 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -798,6 +798,9 @@ def test_string_contains_false(self, test_render): def test_string_index_of(self, test_render): test_render("#set($foo = 'something') $foo.indexOf('e')") + def test_string_substring(self, test_render): + test_render("#set($foo = 'something') $foo.substring(3, 6)") + def test_dict_put_item(self, test_render): template = ( "#set( $ignore = $test_dict.put('k', 'new value') )" diff --git a/tests/test_templating.snapshot.json b/tests/test_templating.snapshot.json index f0026e6..c24ca2d 100644 --- a/tests/test_templating.snapshot.json +++ b/tests/test_templating.snapshot.json @@ -1054,5 +1054,12 @@ "render-result-1-cli": " 3", "render-result-1": " 3" } + }, + "tests/test_templating.py::TestTemplating::test_string_substring": { + "recorded-date": "14-11-2023, 15:43:22", + "recorded-content": { + "render-result-1-cli": " eth", + "render-result-1": " eth" + } } } From 75ab1e70f16939efe585af631f5c6ad1b4d07054 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 14 Nov 2023 15:53:33 +0000 Subject: [PATCH 4/5] Update package metadata --- README.md | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c3c8dbb..aa0fcdb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This is a fork of the original [`airspeed`](https://github.com/purcell/airspeed) ⚠️ Note: This fork of `airspeed` focuses on providing maximum parity with AWS' implementation of Velocity templates (used in, e.g., API Gateway or AppSync). In some cases, the behavior may diverge from the VTL spec, or from the Velocity [reference implementation](https://velocity.apache.org/download.cgi). ## Change Log: +* v0.6.4: add support for string.indexOf, string.substring and array.isEmpty * v0.6.3: array notation for dicts using string literals and merge upstream patches * v0.6.2: add support to contains and toString functions * v0.6.1: improve handling of multi-line dict expressions diff --git a/setup.py b/setup.py index a8a5149..9280dff 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="airspeed-ext", - version="0.6.3", + version="0.6.4", description=( "Airspeed is a powerful and easy-to-use templating engine " "for Python that aims for a high level of compatibility " From 8b4fecfff82ed2cae5a90f26e75436803c4fddeb Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 14 Nov 2023 15:55:47 +0000 Subject: [PATCH 5/5] Format --- airspeed/operators.py | 45 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/airspeed/operators.py b/airspeed/operators.py index 1987698..8aee97e 100644 --- a/airspeed/operators.py +++ b/airspeed/operators.py @@ -112,13 +112,17 @@ def __init__(self, element, exc_info): element.end, element.filename, ) - self.msg = "Error in template '%s' at position " "%d-%d in expression: %s\n%s: %s" % ( - self.filename, - self.start, - self.end, - element.my_text(), - cause.__name__, - value, + self.msg = ( + "Error in template '%s' at position " + "%d-%d in expression: %s\n%s: %s" + % ( + self.filename, + self.start, + self.end, + element.my_text(), + cause.__name__, + value, + ) ) def __str__(self): @@ -372,7 +376,8 @@ def evaluate(self, *args): class Text(_Element): PLAIN = re.compile( - r"((?:[^\\\$#]+|\\[\$#])+|\$[^!\{a-z0-9_]|\$$|#$" r"|#[^\{\}a-zA-Z0-9#\*]+|\\.)(.*)$", + r"((?:[^\\\$#]+|\\[\$#])+|\$[^!\{a-z0-9_]|\$$|#$" + r"|#[^\{\}a-zA-Z0-9#\*]+|\\.)(.*)$", re.S + re.I, ) ESCAPED_CHAR = re.compile(r"\\([\$#]\S+)") @@ -627,7 +632,11 @@ def _call_method(*args): if result is None: return None # TODO: an explicit 'not found' exception? if isinstance(result, _FunctionDefinition): - params = self.parameters and self.parameters.calculate(top_namespace, loader) or [] + params = ( + self.parameters + and self.parameters.calculate(top_namespace, loader) + or [] + ) stream = StoppableStream() result.execute_function(stream, top_namespace, params, loader) result_value = stream.getvalue() @@ -640,8 +649,12 @@ def _call_method(*args): elif self.index is not None: array_index = self.index.calculate(top_namespace, loader) # If list make sure index is an integer - if isinstance(result, list) and not isinstance(array_index, six.integer_types): - raise ValueError("expected integer for array index, got '%s'" % (array_index)) + if isinstance(result, list) and not isinstance( + array_index, six.integer_types + ): + raise ValueError( + "expected integer for array index, got '%s'" % (array_index) + ) try: result = result[array_index] except Exception: @@ -799,7 +812,8 @@ def evaluate(self, *args): class BinaryOperator(_Element): BINARY_OP = re.compile( - r"\s*(>=|<=|<|==|!=|>|%|\|\||&&|or|and|\+|\-|\*|\/|\%|gt|lt|ne|eq|ge" r"|le|not)\s*(.*)$", + r"\s*(>=|<=|<|==|!=|>|%|\|\||&&|or|and|\+|\-|\*|\/|\%|gt|lt|ne|eq|ge" + r"|le|not)\s*(.*)$", re.S, ) OPERATORS = { @@ -1035,7 +1049,9 @@ def evaluate_raw(self, stream, namespace, loader): # set($one.two().three = something) # yet class Assignment(_Element): - START = re.compile(r"\s*\(\s*\$(\w*(?:\.[\w-]+|\[\"\$\w+\"\]*)*)\s*=\s*(.*)$", re.S + re.I) + START = re.compile( + r"\s*\(\s*\$(\w*(?:\.[\w-]+|\[\"\$\w+\"\]*)*)\s*=\s*(.*)$", re.S + re.I + ) END = re.compile(r"\s*\)(?:[ \t]*\r?\n)?(.*)$", re.S + re.M) def parse(self): @@ -1289,7 +1305,8 @@ def evaluate_raw(self, stream, namespace, loader): iter(iterable) except TypeError: raise ValueError( - "value for $%s is not iterable in #foreach: %s" % (self.loop_var_name, iterable) + "value for $%s is not iterable in #foreach: %s" + % (self.loop_var_name, iterable) ) length = len(iterable) for item in iterable: