SES: Improve template parsing (#5853)
This commit is contained in:
		
							parent
							
								
									ba78420314
								
							
						
					
					
						commit
						66507dd898
					
				@ -1,70 +1,6 @@
 | 
				
			|||||||
from enum import Enum
 | 
					from enum import Enum
 | 
				
			||||||
from moto.dynamodb.exceptions import MockValidationException
 | 
					from moto.dynamodb.exceptions import MockValidationException
 | 
				
			||||||
 | 
					from moto.utilities.tokenizer import GenericTokenizer
 | 
				
			||||||
 | 
					 | 
				
			||||||
class KeyConditionExpressionTokenizer:
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Tokenizer for a KeyConditionExpression. Should be used as an iterator.
 | 
					 | 
				
			||||||
    The final character to be returned will be an empty string, to notify the caller that we've reached the end.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, expression):
 | 
					 | 
				
			||||||
        self.expression = expression
 | 
					 | 
				
			||||||
        self.token_pos = 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __iter__(self):
 | 
					 | 
				
			||||||
        self.token_pos = 0
 | 
					 | 
				
			||||||
        return self
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def is_eof(self):
 | 
					 | 
				
			||||||
        return self.peek() == ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def peek(self):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Peek the next character without changing the position
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            return self.expression[self.token_pos]
 | 
					 | 
				
			||||||
        except IndexError:
 | 
					 | 
				
			||||||
            return ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __next__(self):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Returns the next character, or an empty string if we've reached the end of the string.
 | 
					 | 
				
			||||||
        Calling this method again will result in a StopIterator
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            result = self.expression[self.token_pos]
 | 
					 | 
				
			||||||
            self.token_pos += 1
 | 
					 | 
				
			||||||
            return result
 | 
					 | 
				
			||||||
        except IndexError:
 | 
					 | 
				
			||||||
            if self.token_pos == len(self.expression):
 | 
					 | 
				
			||||||
                self.token_pos += 1
 | 
					 | 
				
			||||||
                return ""
 | 
					 | 
				
			||||||
            raise StopIteration
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def skip_characters(self, phrase, case_sensitive=False) -> None:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Skip the characters in the supplied phrase.
 | 
					 | 
				
			||||||
        If any other character is encountered instead, this will fail.
 | 
					 | 
				
			||||||
        If we've already reached the end of the iterator, this will fail.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        for ch in phrase:
 | 
					 | 
				
			||||||
            if case_sensitive:
 | 
					 | 
				
			||||||
                assert self.expression[self.token_pos] == ch
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                assert self.expression[self.token_pos] in [ch.lower(), ch.upper()]
 | 
					 | 
				
			||||||
            self.token_pos += 1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def skip_white_space(self):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Skip the any whitespace characters that are coming up
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            while self.peek() == " ":
 | 
					 | 
				
			||||||
                self.token_pos += 1
 | 
					 | 
				
			||||||
        except IndexError:
 | 
					 | 
				
			||||||
            pass
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EXPRESSION_STAGES(Enum):
 | 
					class EXPRESSION_STAGES(Enum):
 | 
				
			||||||
@ -100,7 +36,7 @@ def parse_expression(
 | 
				
			|||||||
    key_name = comparison = None
 | 
					    key_name = comparison = None
 | 
				
			||||||
    key_values = []
 | 
					    key_values = []
 | 
				
			||||||
    results = []
 | 
					    results = []
 | 
				
			||||||
    tokenizer = KeyConditionExpressionTokenizer(key_condition_expression)
 | 
					    tokenizer = GenericTokenizer(key_condition_expression)
 | 
				
			||||||
    for crnt_char in tokenizer:
 | 
					    for crnt_char in tokenizer:
 | 
				
			||||||
        if crnt_char == " ":
 | 
					        if crnt_char == " ":
 | 
				
			||||||
            if current_stage == EXPRESSION_STAGES.INITIAL_STAGE:
 | 
					            if current_stage == EXPRESSION_STAGES.INITIAL_STAGE:
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,3 @@
 | 
				
			|||||||
import re
 | 
					 | 
				
			||||||
import json
 | 
					import json
 | 
				
			||||||
import email
 | 
					import email
 | 
				
			||||||
import datetime
 | 
					import datetime
 | 
				
			||||||
@ -23,9 +22,9 @@ from .exceptions import (
 | 
				
			|||||||
    RuleSetNameAlreadyExists,
 | 
					    RuleSetNameAlreadyExists,
 | 
				
			||||||
    RuleSetDoesNotExist,
 | 
					    RuleSetDoesNotExist,
 | 
				
			||||||
    RuleAlreadyExists,
 | 
					    RuleAlreadyExists,
 | 
				
			||||||
    MissingRenderingAttributeException,
 | 
					 | 
				
			||||||
    ConfigurationSetAlreadyExists,
 | 
					    ConfigurationSetAlreadyExists,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from .template import parse_template
 | 
				
			||||||
from .utils import get_random_message_id, is_valid_address
 | 
					from .utils import get_random_message_id, is_valid_address
 | 
				
			||||||
from .feedback import COMMON_MAIL, BOUNCE, COMPLAINT, DELIVERY
 | 
					from .feedback import COMMON_MAIL, BOUNCE, COMPLAINT, DELIVERY
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -106,17 +105,6 @@ class SESQuota(BaseModel):
 | 
				
			|||||||
        return self.sent
 | 
					        return self.sent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def are_all_variables_present(template, template_data):
 | 
					 | 
				
			||||||
    subject_part = template["subject_part"]
 | 
					 | 
				
			||||||
    text_part = template["text_part"]
 | 
					 | 
				
			||||||
    html_part = template["html_part"]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for var in re.findall("{{(.+?)}}", subject_part + text_part + html_part):
 | 
					 | 
				
			||||||
        if not template_data.get(var):
 | 
					 | 
				
			||||||
            return var, False
 | 
					 | 
				
			||||||
    return None, True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SESBackend(BaseBackend):
 | 
					class SESBackend(BaseBackend):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Responsible for mocking calls to SES.
 | 
					    Responsible for mocking calls to SES.
 | 
				
			||||||
@ -469,18 +457,13 @@ class SESBackend(BaseBackend):
 | 
				
			|||||||
                "Template rendering data is invalid"
 | 
					                "Template rendering data is invalid"
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var, are_variables_present = are_all_variables_present(template, template_data)
 | 
					 | 
				
			||||||
        if not are_variables_present:
 | 
					 | 
				
			||||||
            raise MissingRenderingAttributeException(var)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        subject_part = template["subject_part"]
 | 
					        subject_part = template["subject_part"]
 | 
				
			||||||
        text_part = template["text_part"]
 | 
					        text_part = template["text_part"]
 | 
				
			||||||
        html_part = template["html_part"]
 | 
					        html_part = template["html_part"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for key, value in template_data.items():
 | 
					        subject_part = parse_template(str(subject_part), template_data)
 | 
				
			||||||
            subject_part = str.replace(str(subject_part), "{{" + key + "}}", value)
 | 
					        text_part = parse_template(str(text_part), template_data)
 | 
				
			||||||
            text_part = str.replace(str(text_part), "{{" + key + "}}", value)
 | 
					        html_part = parse_template(str(html_part), template_data)
 | 
				
			||||||
            html_part = str.replace(str(html_part), "{{" + key + "}}", value)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        email_obj = MIMEMultipart("alternative")
 | 
					        email_obj = MIMEMultipart("alternative")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										45
									
								
								moto/ses/template.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								moto/ses/template.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					from .exceptions import MissingRenderingAttributeException
 | 
				
			||||||
 | 
					from moto.utilities.tokenizer import GenericTokenizer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def parse_template(template, template_data, tokenizer=None, until=None):
 | 
				
			||||||
 | 
					    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 == "{":
 | 
				
			||||||
 | 
					            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)
 | 
				
			||||||
 | 
					                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
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        parsed += char
 | 
				
			||||||
 | 
					    return parsed
 | 
				
			||||||
							
								
								
									
										62
									
								
								moto/utilities/tokenizer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								moto/utilities/tokenizer.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					class GenericTokenizer:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Tokenizer for a KeyConditionExpression. Should be used as an iterator.
 | 
				
			||||||
 | 
					    The final character to be returned will be an empty string, to notify the caller that we've reached the end.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, expression):
 | 
				
			||||||
 | 
					        self.expression = expression
 | 
				
			||||||
 | 
					        self.token_pos = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __iter__(self):
 | 
				
			||||||
 | 
					        return self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_eof(self):
 | 
				
			||||||
 | 
					        return self.peek() == ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def peek(self, length=1):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Peek the next character without changing the position
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return self.expression[self.token_pos : self.token_pos + length]
 | 
				
			||||||
 | 
					        except IndexError:
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __next__(self):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Returns the next character, or an empty string if we've reached the end of the string.
 | 
				
			||||||
 | 
					        Calling this method again will result in a StopIterator
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            result = self.expression[self.token_pos]
 | 
				
			||||||
 | 
					            self.token_pos += 1
 | 
				
			||||||
 | 
					            return result
 | 
				
			||||||
 | 
					        except IndexError:
 | 
				
			||||||
 | 
					            if self.token_pos == len(self.expression):
 | 
				
			||||||
 | 
					                self.token_pos += 1
 | 
				
			||||||
 | 
					                return ""
 | 
				
			||||||
 | 
					            raise StopIteration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def skip_characters(self, phrase, case_sensitive=False) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Skip the characters in the supplied phrase.
 | 
				
			||||||
 | 
					        If any other character is encountered instead, this will fail.
 | 
				
			||||||
 | 
					        If we've already reached the end of the iterator, this will fail.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        for ch in phrase:
 | 
				
			||||||
 | 
					            if case_sensitive:
 | 
				
			||||||
 | 
					                assert self.expression[self.token_pos] == ch
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                assert self.expression[self.token_pos] in [ch.lower(), ch.upper()]
 | 
				
			||||||
 | 
					            self.token_pos += 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def skip_white_space(self):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Skip any whitespace characters that are coming up
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            while self.peek() == " ":
 | 
				
			||||||
 | 
					                self.token_pos += 1
 | 
				
			||||||
 | 
					        except IndexError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
@ -1268,6 +1268,35 @@ def test_render_template():
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@mock_ses
 | 
				
			||||||
 | 
					def test_render_template__with_foreach():
 | 
				
			||||||
 | 
					    conn = boto3.client("ses", region_name="us-east-1")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    kwargs = dict(
 | 
				
			||||||
 | 
					        TemplateName="MTT",
 | 
				
			||||||
 | 
					        TemplateData=json.dumps(
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "items": [
 | 
				
			||||||
 | 
					                    {"type": "dog", "name": "bobby"},
 | 
				
			||||||
 | 
					                    {"type": "cat", "name": "pedro"},
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    conn.create_template(
 | 
				
			||||||
 | 
					        Template={
 | 
				
			||||||
 | 
					            "TemplateName": "MTT",
 | 
				
			||||||
 | 
					            "SubjectPart": "..",
 | 
				
			||||||
 | 
					            "TextPart": "..",
 | 
				
			||||||
 | 
					            "HtmlPart": "{{#each items}} {{name}} is a {{type}}, {{/each}}",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    result = conn.test_render_template(**kwargs)
 | 
				
			||||||
 | 
					    result["RenderedTemplate"].should.contain("bobby is a dog")
 | 
				
			||||||
 | 
					    result["RenderedTemplate"].should.contain("pedro is a cat")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@mock_ses
 | 
					@mock_ses
 | 
				
			||||||
def test_update_ses_template():
 | 
					def test_update_ses_template():
 | 
				
			||||||
    conn = boto3.client("ses", region_name="us-east-1")
 | 
					    conn = boto3.client("ses", region_name="us-east-1")
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								tests/test_ses/test_templating.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								tests/test_ses/test_templating.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					from moto.ses.template import parse_template
 | 
				
			||||||
 | 
					import sure  # noqa # pylint: disable=unused-import
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_template_without_args():
 | 
				
			||||||
 | 
					    parse_template("some template", template_data={}).should.equal("some template")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_template_with_simple_arg():
 | 
				
			||||||
 | 
					    t = "Dear {{name}},"
 | 
				
			||||||
 | 
					    parse_template(t, template_data={"name": "John"}).should.equal("Dear John,")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_template_with_foreach():
 | 
				
			||||||
 | 
					    t = "List:{{#each l}} - {{obj}}{{/each}} and other things"
 | 
				
			||||||
 | 
					    kwargs = {"l": [{"obj": "it1"}, {"obj": "it2"}]}
 | 
				
			||||||
 | 
					    parse_template(t, kwargs).should.equal("List: - it1 - it2 and other things")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_template_with_multiple_foreach():
 | 
				
			||||||
 | 
					    t = "{{#each l}} - {{obj}}{{/each}} and list 2 {{#each l2}} {{o1}} {{o2}} {{/each}}"
 | 
				
			||||||
 | 
					    kwargs = {
 | 
				
			||||||
 | 
					        "l": [{"obj": "it1"}, {"obj": "it2"}],
 | 
				
			||||||
 | 
					        "l2": [{"o1": "ob1", "o2": "ob2"}, {"o1": "oc1", "o2": "oc2"}],
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    parse_template(t, kwargs).should.equal(" - it1 - it2 and list 2  ob1 ob2  oc1 oc2 ")
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user