diff --git a/.github/workflows/tests_real_aws.yml b/.github/workflows/tests_real_aws.yml
index 6ef5bf734..342769732 100644
--- a/.github/workflows/tests_real_aws.yml
+++ b/.github/workflows/tests_real_aws.yml
@@ -42,4 +42,4 @@ jobs:
env:
MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }}
run: |
- pytest -sv tests/test_ec2/ tests/test_s3 -m aws_verified
+ pytest -sv tests/test_ec2/ tests/test_ses/ tests/test_s3 -m aws_verified
diff --git a/moto/ses/models.py b/moto/ses/models.py
index 1303a7a6c..0427b4105 100644
--- a/moto/ses/models.py
+++ b/moto/ses/models.py
@@ -518,6 +518,9 @@ class SESBackend(BaseBackend):
)
return rendered_template
+ def delete_template(self, name: str) -> None:
+ self.templates.pop(name)
+
def create_receipt_rule_set(self, rule_set_name: str) -> None:
if self.receipt_rule_set.get(rule_set_name) is not None:
raise RuleSetNameAlreadyExists("Duplicate Receipt Rule Set Name.")
diff --git a/moto/ses/responses.py b/moto/ses/responses.py
index 186feddb8..ceef3ae19 100644
--- a/moto/ses/responses.py
+++ b/moto/ses/responses.py
@@ -281,6 +281,11 @@ class EmailResponse(BaseResponse):
template = self.response_template(RENDER_TEMPLATE)
return template.render(template=rendered_template)
+ def delete_template(self) -> str:
+ name = self._get_param("TemplateName")
+ self.backend.delete_template(name)
+ return self.response_template(DELETE_TEMPLATE).render()
+
def create_receipt_rule_set(self) -> str:
rule_set_name = self._get_param("RuleSetName")
self.backend.create_receipt_rule_set(rule_set_name)
@@ -627,6 +632,14 @@ RENDER_TEMPLATE = """
"""
+DELETE_TEMPLATE = """
+
+
+
+ 47e0ef1a-9bf2-11e1-9279-0100e8cf12ba
+
+"""
+
CREATE_RECEIPT_RULE_SET = """
diff --git a/moto/ses/template.py b/moto/ses/template.py
index a153cf0db..987dc9fb9 100644
--- a/moto/ses/template.py
+++ b/moto/ses/template.py
@@ -1,53 +1,180 @@
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, Type
from .exceptions import MissingRenderingAttributeException
from moto.utilities.tokenizer import GenericTokenizer
+class BlockProcessor:
+ def __init__(
+ self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+ ):
+ self.template = template
+ self.template_data = template_data
+ self.tokenizer = tokenizer
+
+ def parse(self) -> str:
+ # Added to make MyPy happy
+ # Not all implementations have this method
+ # It's up to the caller to know whether to call this method
+ raise NotImplementedError
+
+
+class EachBlockProcessor(BlockProcessor):
+ def __init__(
+ self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+ ):
+ self.template = template
+ self.tokenizer = tokenizer
+
+ self.tokenizer.skip_characters("#each")
+ self.tokenizer.skip_white_space()
+ var_name = self.tokenizer.read_until("}}").strip()
+ self.tokenizer.skip_characters("}}")
+ self.template_data = template_data.get(var_name, [])
+
+ def parse(self) -> str:
+ parsed = ""
+ current_pos = self.tokenizer.token_pos
+
+ for template_data in self.template_data:
+ self.tokenizer.token_pos = current_pos
+ for char in self.tokenizer:
+ if char == "{" and self.tokenizer.peek() == "{":
+ self.tokenizer.skip_characters("{")
+ self.tokenizer.skip_white_space()
+
+ _processor = get_processor(self.tokenizer)(
+ self.template, template_data, self.tokenizer # type: ignore
+ )
+ # If we've reached the end, we should stop processing
+ # Our parent will continue with whatever comes after {{/each}}
+ if type(_processor) == EachEndBlockProcessor:
+ break
+ # If we've encountered another processor, they can continue
+ parsed += _processor.parse()
+
+ continue
+
+ parsed += char
+
+ return parsed
+
+
+class EachEndBlockProcessor(BlockProcessor):
+ def __init__(
+ self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+ ):
+ super().__init__(template, template_data, tokenizer)
+
+ self.tokenizer.skip_characters("/each")
+ self.tokenizer.skip_white_space()
+ self.tokenizer.skip_characters("}}")
+
+
+class IfBlockProcessor(BlockProcessor):
+ def __init__(
+ self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+ ):
+ super().__init__(template, template_data, tokenizer)
+
+ self.tokenizer.skip_characters("#if")
+ self.tokenizer.skip_white_space()
+ condition = self.tokenizer.read_until("}}").strip()
+ self.tokenizer.skip_characters("}}")
+ self.parse_contents = template_data.get(condition)
+
+ def parse(self) -> str:
+ parsed = ""
+
+ for char in self.tokenizer:
+ if char == "{" and self.tokenizer.peek() == "{":
+ self.tokenizer.skip_characters("{")
+ self.tokenizer.skip_white_space()
+
+ _processor = get_processor(self.tokenizer)(
+ self.template, self.template_data, self.tokenizer
+ )
+ if type(_processor) == IfEndBlockProcessor:
+ break
+ elif type(_processor) == ElseBlockProcessor:
+ self.parse_contents = not self.parse_contents
+ continue
+ if self.parse_contents:
+ parsed += _processor.parse()
+
+ continue
+
+ if self.parse_contents:
+ parsed += char
+
+ return parsed
+
+
+class IfEndBlockProcessor(BlockProcessor):
+ def __init__(
+ self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+ ):
+ super().__init__(template, template_data, tokenizer)
+
+ self.tokenizer.skip_characters("/if")
+ self.tokenizer.skip_white_space()
+ self.tokenizer.skip_characters("}}")
+
+
+class ElseBlockProcessor(BlockProcessor):
+ def __init__(
+ self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+ ):
+ super().__init__(template, template_data, tokenizer)
+
+ self.tokenizer.skip_characters("else")
+ self.tokenizer.skip_white_space()
+ self.tokenizer.skip_characters("}}")
+
+
+class VarBlockProcessor(BlockProcessor):
+ def parse(self) -> str:
+ var_name = self.tokenizer.read_until("}}").strip()
+ if self.template_data.get(var_name) is None:
+ raise MissingRenderingAttributeException(var_name)
+ data: str = self.template_data.get(var_name) # type: ignore
+ self.tokenizer.skip_white_space()
+ self.tokenizer.skip_characters("}}")
+ return data
+
+
+def get_processor(tokenizer: GenericTokenizer) -> Type[BlockProcessor]:
+ if tokenizer.peek(5) == "#each":
+ return EachBlockProcessor
+ if tokenizer.peek(5) == "/each":
+ return EachEndBlockProcessor
+ if tokenizer.peek(3) == "#if":
+ return IfBlockProcessor
+ if tokenizer.peek(3) == "/if":
+ return IfEndBlockProcessor
+ if tokenizer.peek(4) == "else":
+ return ElseBlockProcessor
+ return VarBlockProcessor
+
+
def parse_template(
template: str,
template_data: Dict[str, Any],
tokenizer: Optional[GenericTokenizer] = None,
- until: Optional[str] = None,
) -> str:
tokenizer = tokenizer or GenericTokenizer(template)
- state = ""
+
parsed = ""
- var_name = ""
for char in tokenizer:
- if until is not None and (char + tokenizer.peek(len(until) - 1)) == until:
- return parsed
if char == "{" and tokenizer.peek() == "{":
# Two braces next to each other indicate a variable/language construct such as for-each
+ # We have different processors handling different constructs
tokenizer.skip_characters("{")
tokenizer.skip_white_space()
- if tokenizer.peek() == "#":
- state = "LOOP"
- tokenizer.skip_characters("#each")
- tokenizer.skip_white_space()
- else:
- state = "VAR"
- continue
- if char == "}":
- if state in ["LOOP", "VAR"]:
- tokenizer.skip_characters("}")
- if state == "VAR":
- if template_data.get(var_name) is None:
- raise MissingRenderingAttributeException(var_name)
- parsed += template_data.get(var_name) # type: ignore
- else:
- current_position = tokenizer.token_pos
- for item in template_data.get(var_name, []):
- tokenizer.token_pos = current_position
- parsed += parse_template(
- template, item, tokenizer, until="{{/each}}"
- )
- tokenizer.skip_characters("{/each}}")
- var_name = ""
- state = ""
- continue
- if state in ["LOOP", "VAR"]:
- var_name += char
+
+ _processor = get_processor(tokenizer)(template, template_data, tokenizer)
+ parsed += _processor.parse()
continue
+
parsed += char
return parsed
diff --git a/moto/utilities/tokenizer.py b/moto/utilities/tokenizer.py
index 7872f8a7c..38433d84b 100644
--- a/moto/utilities/tokenizer.py
+++ b/moto/utilities/tokenizer.py
@@ -38,6 +38,14 @@ class GenericTokenizer:
return ""
raise StopIteration
+ def read_until(self, phrase: str) -> str:
+ chars_read = ""
+ for char in self:
+ chars_read += char
+ if self.peek(len(phrase)) == phrase:
+ return chars_read
+ return chars_read
+
def skip_characters(self, phrase: str, case_sensitive: bool = False) -> None:
"""
Skip the characters in the supplied phrase.
diff --git a/tests/test_ses/__init__.py b/tests/test_ses/__init__.py
index 08a1c1568..914f7dfe5 100644
--- a/tests/test_ses/__init__.py
+++ b/tests/test_ses/__init__.py
@@ -1 +1,28 @@
-# This file is intentionally left blank.
+import os
+from functools import wraps
+from moto import mock_ses
+
+
+def ses_aws_verified(func):
+ """
+ Function that is verified to work against AWS.
+ Can be run against AWS at any time by setting:
+ MOTO_TEST_ALLOW_AWS_REQUEST=true
+
+ If this environment variable is not set, the function runs in a `mock_ses` context.
+ """
+
+ @wraps(func)
+ def pagination_wrapper():
+ allow_aws_request = (
+ os.environ.get("MOTO_TEST_ALLOW_AWS_REQUEST", "false").lower() == "true"
+ )
+
+ if allow_aws_request:
+ resp = func()
+ else:
+ with mock_ses():
+ resp = func()
+ return resp
+
+ return pagination_wrapper
diff --git a/tests/test_ses/test_ses_boto3.py b/tests/test_ses/test_ses_boto3.py
index 7e04985c9..eda9cb1b0 100644
--- a/tests/test_ses/test_ses_boto3.py
+++ b/tests/test_ses/test_ses_boto3.py
@@ -7,6 +7,7 @@ from botocore.exceptions import ClientError
from botocore.exceptions import ParamValidationError
import pytest
from moto import mock_ses
+from . import ses_aws_verified
@mock_ses
@@ -1296,8 +1297,8 @@ def test_render_template():
)
-@mock_ses
-def test_render_template__with_foreach():
+@ses_aws_verified
+def test_render_template__advanced():
conn = boto3.client("ses", region_name="us-east-1")
kwargs = {
@@ -1305,24 +1306,27 @@ def test_render_template__with_foreach():
"TemplateData": json.dumps(
{
"items": [
- {"type": "dog", "name": "bobby"},
- {"type": "cat", "name": "pedro"},
+ {"type": "dog", "name": "bobby", "best": True},
+ {"type": "cat", "name": "pedro", "best": False},
]
}
),
}
- conn.create_template(
- Template={
- "TemplateName": "MTT",
- "SubjectPart": "..",
- "TextPart": "..",
- "HtmlPart": "{{#each items}} {{name}} is a {{type}}, {{/each}}",
- }
- )
- result = conn.test_render_template(**kwargs)
- assert "bobby is a dog" in result["RenderedTemplate"]
- assert "pedro is a cat" in result["RenderedTemplate"]
+ try:
+ conn.create_template(
+ Template={
+ "TemplateName": "MTT",
+ "SubjectPart": "..",
+ "TextPart": "..",
+ "HtmlPart": "{{#each items}} {{name}} is {{#if best}}the best{{else}}a {{type}}{{/if}}, {{/each}}",
+ }
+ )
+ result = conn.test_render_template(**kwargs)
+ assert "bobby is the best" in result["RenderedTemplate"]
+ assert "pedro is a cat" in result["RenderedTemplate"]
+ finally:
+ conn.delete_template(TemplateName="MTT")
@mock_ses
diff --git a/tests/test_ses/test_templating.py b/tests/test_ses/test_templating.py
index 9d2ba4372..723d77649 100644
--- a/tests/test_ses/test_templating.py
+++ b/tests/test_ses/test_templating.py
@@ -34,3 +34,50 @@ def test_template_with_single_curly_brace():
assert parse_template(template, template_data={"name": "John"}) == (
"Dear John my {brace} is fine."
)
+
+
+def test_template_with_if():
+ template = "Dear {{ #if name }}John{{ /if }}"
+ assert parse_template(template, template_data={"name": True}) == "Dear John"
+ assert parse_template(template, template_data={"name": False}) == "Dear "
+
+
+def test_template_with_if_else():
+ template = "Dear {{ #if name }}John{{ else }}English{{ /if }}"
+ assert parse_template(template, template_data={"name": True}) == "Dear John"
+ assert parse_template(template, template_data={"name": False}) == "Dear English"
+
+
+def test_template_with_nested_foreaches():
+ template = (
+ "{{#each l}} - {{obj}} {{#each l2}} {{o1}} {{/each}} infix-each {{/each}}"
+ )
+ kwargs = {
+ "name": True,
+ "l": [
+ {
+ "obj": "it1",
+ "l2": [{"o1": "ob1", "o2": "ob2"}, {"o1": "oc1", "o2": "oc2"}],
+ },
+ {"obj": "it2", "l2": [{"o1": "ob3"}]},
+ ],
+ }
+ assert (
+ parse_template(template, kwargs)
+ == " - it1 ob1 oc1 infix-each - it2 ob3 infix-each "
+ )
+
+
+def test_template_with_nested_ifs_and_foreaches():
+ template = "{{#each l}} - {{obj}} {{#if name}} post-if {{#each l2}} {{o1}} {{/each}} pre-if-end {{else}}elsedata{{/if}} post-if {{/each}}"
+ kwargs = {
+ "name": True,
+ "l": [
+ {"obj": "it1", "name": True, "l2": [{"o1": "ob1"}]},
+ {"obj": "it2", "name": False},
+ ],
+ }
+ assert (
+ parse_template(template, kwargs)
+ == " - it1 post-if ob1 pre-if-end post-if - it2 elsedata post-if "
+ )