Merge pull request #35 from spulec/master

Merge upstream
This commit is contained in:
Bert Blommers 2020-03-20 12:10:24 +00:00 committed by GitHub
commit 66b26cd7b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 291 additions and 33 deletions

View File

@ -8,6 +8,7 @@ from boto3 import Session
from moto.compat import OrderedDict
from moto.core import BaseBackend, BaseModel
from moto.core.utils import iso_8601_datetime_without_milliseconds
from .parsing import ResourceMap, OutputMap
from .utils import (
@ -240,6 +241,7 @@ class FakeStack(BaseModel):
self.output_map = self._create_output_map()
self._add_stack_event("CREATE_COMPLETE")
self.status = "CREATE_COMPLETE"
self.creation_time = datetime.utcnow()
def _create_resource_map(self):
resource_map = ResourceMap(
@ -259,6 +261,10 @@ class FakeStack(BaseModel):
output_map.create()
return output_map
@property
def creation_time_iso_8601(self):
return iso_8601_datetime_without_milliseconds(self.creation_time)
def _add_stack_event(
self, resource_status, resource_status_reason=None, resource_properties=None
):

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals
import functools
import json
import logging
import copy
import warnings
@ -24,7 +25,8 @@ from moto.rds import models as rds_models
from moto.rds2 import models as rds2_models
from moto.redshift import models as redshift_models
from moto.route53 import models as route53_models
from moto.s3 import models as s3_models
from moto.s3 import models as s3_models, s3_backend
from moto.s3.utils import bucket_and_name_from_url
from moto.sns import models as sns_models
from moto.sqs import models as sqs_models
from moto.core import ACCOUNT_ID
@ -150,7 +152,10 @@ def clean_json(resource_json, resources_map):
map_path = resource_json["Fn::FindInMap"][1:]
result = resources_map[map_name]
for path in map_path:
result = result[clean_json(path, resources_map)]
if "Fn::Transform" in result:
result = resources_map[clean_json(path, resources_map)]
else:
result = result[clean_json(path, resources_map)]
return result
if "Fn::GetAtt" in resource_json:
@ -470,6 +475,17 @@ class ResourceMap(collections_abc.Mapping):
def load_mapping(self):
self._parsed_resources.update(self._template.get("Mappings", {}))
def transform_mapping(self):
for k, v in self._template.get("Mappings", {}).items():
if "Fn::Transform" in v:
name = v["Fn::Transform"]["Name"]
params = v["Fn::Transform"]["Parameters"]
if name == "AWS::Include":
location = params["Location"]
bucket_name, name = bucket_and_name_from_url(location)
key = s3_backend.get_key(bucket_name, name)
self._parsed_resources.update(json.loads(key.value))
def load_parameters(self):
parameter_slots = self._template.get("Parameters", {})
for parameter_name, parameter in parameter_slots.items():
@ -515,6 +531,7 @@ class ResourceMap(collections_abc.Mapping):
def create(self):
self.load_mapping()
self.transform_mapping()
self.load_parameters()
self.load_conditions()

View File

@ -662,7 +662,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
<member>
<StackName>{{ stack.name }}</StackName>
<StackId>{{ stack.stack_id }}</StackId>
<CreationTime>2010-07-27T22:28:28Z</CreationTime>
<CreationTime>{{ stack.creation_time_iso_8601 }}</CreationTime>
<StackStatus>{{ stack.status }}</StackStatus>
{% if stack.notification_arns %}
<NotificationARNs>
@ -803,7 +803,7 @@ LIST_STACKS_RESPONSE = """<ListStacksResponse>
<StackId>{{ stack.stack_id }}</StackId>
<StackStatus>{{ stack.status }}</StackStatus>
<StackName>{{ stack.name }}</StackName>
<CreationTime>2011-05-23T15:47:44Z</CreationTime>
<CreationTime>{{ stack.creation_time_iso_8601 }}</CreationTime>
<TemplateDescription>{{ stack.description }}</TemplateDescription>
</member>
{% endfor %}

View File

@ -5,6 +5,7 @@ from boto3 import Session
from moto.core.utils import iso_8601_datetime_without_milliseconds
from moto.core import BaseBackend, BaseModel
from moto.core.exceptions import RESTError
from moto.logs import logs_backends
from datetime import datetime, timedelta
from dateutil.tz import tzutc
from uuid import uuid4
@ -428,12 +429,9 @@ class LogGroup(BaseModel):
cls, resource_name, cloudformation_json, region_name
):
properties = cloudformation_json["Properties"]
spec = {"LogGroupName": properties["LogGroupName"]}
optional_properties = "Tags".split()
for prop in optional_properties:
if prop in properties:
spec[prop] = properties[prop]
return LogGroup(spec)
log_group_name = properties["LogGroupName"]
tags = properties.get("Tags", {})
return logs_backends[region_name].create_log_group(log_group_name, tags)
cloudwatch_backends = {}

View File

@ -146,6 +146,9 @@ class DynamoType(object):
def __eq__(self, other):
return self.type == other.type and self.value == other.value
def __ne__(self, other):
return self.type != other.type or self.value != other.value
def __lt__(self, other):
return self.cast_value < other.cast_value
@ -679,6 +682,10 @@ class Table(BaseModel):
self.throughput["NumberOfDecreasesToday"] = 0
self.indexes = indexes
self.global_indexes = global_indexes if global_indexes else []
for index in self.global_indexes:
index[
"IndexStatus"
] = "ACTIVE" # One of 'CREATING'|'UPDATING'|'DELETING'|'ACTIVE'
self.created_at = datetime.datetime.utcnow()
self.items = defaultdict(dict)
self.table_arn = self._generate_arn(table_name)
@ -981,8 +988,13 @@ class Table(BaseModel):
if index_name:
if index_range_key:
# Convert to float if necessary to ensure proper ordering
def conv(x):
return float(x.value) if x.type == "N" else x.value
results.sort(
key=lambda item: item.attrs[index_range_key["AttributeName"]].value
key=lambda item: conv(item.attrs[index_range_key["AttributeName"]])
if item.attrs.get(index_range_key["AttributeName"])
else None
)

View File

@ -1,9 +1,12 @@
from __future__ import unicode_literals
import itertools
import copy
import json
import six
import re
import itertools
import six
from moto.core.responses import BaseResponse
from moto.core.utils import camelcase_to_underscores, amzn_request_id
from .exceptions import InvalidIndexNameError, InvalidUpdateExpression, ItemSizeTooLarge
@ -711,7 +714,8 @@ class DynamoHandler(BaseResponse):
attribute_updates = self.body.get("AttributeUpdates")
expression_attribute_names = self.body.get("ExpressionAttributeNames", {})
expression_attribute_values = self.body.get("ExpressionAttributeValues", {})
existing_item = self.dynamodb_backend.get_item(name, key)
# We need to copy the item in order to avoid it being modified by the update_item operation
existing_item = copy.deepcopy(self.dynamodb_backend.get_item(name, key))
if existing_item:
existing_attributes = existing_item.to_json()["Attributes"]
else:
@ -797,14 +801,39 @@ class DynamoHandler(BaseResponse):
k: v for k, v in existing_attributes.items() if k in changed_attributes
}
elif return_values == "UPDATED_NEW":
item_dict["Attributes"] = {
k: v
for k, v in item_dict["Attributes"].items()
if k in changed_attributes
}
item_dict["Attributes"] = self._build_updated_new_attributes(
existing_attributes, item_dict["Attributes"]
)
return dynamo_json_dump(item_dict)
def _build_updated_new_attributes(self, original, changed):
if type(changed) != type(original):
return changed
else:
if type(changed) is dict:
return {
key: self._build_updated_new_attributes(
original.get(key, None), changed[key]
)
for key in changed.keys()
if changed[key] != original.get(key, None)
}
elif type(changed) in (set, list):
if len(changed) != len(original):
return changed
else:
return [
self._build_updated_new_attributes(
original[index], changed[index]
)
for index in range(len(changed))
]
elif changed != original:
return changed
else:
return None
def describe_limits(self):
return json.dumps(
{

View File

@ -405,6 +405,7 @@ class LogsBackend(BaseBackend):
if log_group_name in self.groups:
raise ResourceAlreadyExistsException()
self.groups[log_group_name] = LogGroup(self.region_name, log_group_name, tags)
return self.groups[log_group_name]
def ensure_log_group(self, log_group_name, tags):
if log_group_name in self.groups:

View File

@ -12,6 +12,7 @@ import codecs
import random
import string
import tempfile
import threading
import sys
import time
import uuid
@ -110,6 +111,7 @@ class FakeKey(BaseModel):
self._value_buffer = tempfile.SpooledTemporaryFile(max_size=max_buffer_size)
self._max_buffer_size = max_buffer_size
self.value = value
self.lock = threading.Lock()
@property
def version_id(self):
@ -117,8 +119,12 @@ class FakeKey(BaseModel):
@property
def value(self):
self.lock.acquire()
self._value_buffer.seek(0)
return self._value_buffer.read()
r = self._value_buffer.read()
r = copy.copy(r)
self.lock.release()
return r
@value.setter
def value(self, new_value):
@ -130,6 +136,7 @@ class FakeKey(BaseModel):
if isinstance(new_value, six.text_type):
new_value = new_value.encode(DEFAULT_TEXT_ENCODING)
self._value_buffer.write(new_value)
self.contentsize = len(new_value)
def copy(self, new_name=None, new_is_versioned=None):
r = copy.deepcopy(self)
@ -157,6 +164,7 @@ class FakeKey(BaseModel):
self.acl = acl
def append_to_value(self, value):
self.contentsize += len(value)
self._value_buffer.seek(0, os.SEEK_END)
self._value_buffer.write(value)
@ -229,8 +237,7 @@ class FakeKey(BaseModel):
@property
def size(self):
self._value_buffer.seek(0, os.SEEK_END)
return self._value_buffer.tell()
return self.contentsize
@property
def storage_class(self):
@ -249,6 +256,7 @@ class FakeKey(BaseModel):
state = self.__dict__.copy()
state["value"] = self.value
del state["_value_buffer"]
del state["lock"]
return state
def __setstate__(self, state):
@ -258,6 +266,7 @@ class FakeKey(BaseModel):
max_size=self._max_buffer_size
)
self.value = state["value"]
self.lock = threading.Lock()
class FakeMultipart(BaseModel):
@ -284,7 +293,7 @@ class FakeMultipart(BaseModel):
etag = etag.replace('"', "")
if part is None or part_etag != etag:
raise InvalidPart()
if last is not None and len(last.value) < UPLOAD_PART_MIN_SIZE:
if last is not None and last.contentsize < UPLOAD_PART_MIN_SIZE:
raise EntityTooSmall()
md5s.extend(decode_hex(part_etag)[0])
total.extend(part.value)

View File

@ -35,6 +35,17 @@ def bucket_name_from_url(url):
return None
# 'owi-common-cf', 'snippets/test.json' = bucket_and_name_from_url('s3://owi-common-cf/snippets/test.json')
def bucket_and_name_from_url(url):
prefix = "s3://"
if url.startswith(prefix):
bucket_name = url[len(prefix) : url.index("/", len(prefix))]
key = url[url.index("/", len(prefix)) + 1 :]
return bucket_name, key
else:
return None, None
REGION_URL_REGEX = re.compile(
r"^https?://(s3[-\.](?P<region1>.+)\.amazonaws\.com/(.+)|"
r"(.+)\.s3[-\.](?P<region2>.+)\.amazonaws\.com)/?"

View File

@ -91,9 +91,11 @@ class SESBackend(BaseBackend):
return host in self.domains
def verify_email_identity(self, address):
_, address = parseaddr(address)
self.addresses.append(address)
def verify_email_address(self, address):
_, address = parseaddr(address)
self.email_addresses.append(address)
def verify_domain(self, domain):

View File

@ -2,6 +2,8 @@ from __future__ import unicode_literals
import json
from collections import OrderedDict
from datetime import datetime, timedelta
import pytz
import boto3
from botocore.exceptions import ClientError
@ -911,6 +913,10 @@ def test_describe_stack_by_name():
stack = cf_conn.describe_stacks(StackName="test_stack")["Stacks"][0]
stack["StackName"].should.equal("test_stack")
two_secs_ago = datetime.now(tz=pytz.UTC) - timedelta(seconds=2)
assert (
two_secs_ago < stack["CreationTime"] < datetime.now(tz=pytz.UTC)
), "Stack should have been created recently"
@mock_cloudformation

View File

@ -32,12 +32,14 @@ from moto import (
mock_iam_deprecated,
mock_kms,
mock_lambda,
mock_logs,
mock_rds_deprecated,
mock_rds2,
mock_rds2_deprecated,
mock_redshift,
mock_redshift_deprecated,
mock_route53_deprecated,
mock_s3,
mock_sns_deprecated,
mock_sqs,
mock_sqs_deprecated,
@ -2332,3 +2334,48 @@ def test_stack_dynamodb_resources_integration():
response["Item"]["Sales"].should.equal(Decimal("10"))
response["Item"]["NumberOfSongs"].should.equal(Decimal("5"))
response["Item"]["Album"].should.equal("myAlbum")
@mock_cloudformation
@mock_logs
@mock_s3
def test_create_log_group_using_fntransform():
s3_resource = boto3.resource("s3")
s3_resource.create_bucket(
Bucket="owi-common-cf",
CreateBucketConfiguration={"LocationConstraint": "us-west-2"},
)
s3_resource.Object("owi-common-cf", "snippets/test.json").put(
Body=json.dumps({"lgname": {"name": "some-log-group"}})
)
template = {
"AWSTemplateFormatVersion": "2010-09-09",
"Mappings": {
"EnvironmentMapping": {
"Fn::Transform": {
"Name": "AWS::Include",
"Parameters": {"Location": "s3://owi-common-cf/snippets/test.json"},
}
}
},
"Resources": {
"LogGroup": {
"Properties": {
"LogGroupName": {
"Fn::FindInMap": ["EnvironmentMapping", "lgname", "name"]
},
"RetentionInDays": 90,
},
"Type": "AWS::Logs::LogGroup",
}
},
}
cf_conn = boto3.client("cloudformation", "us-west-2")
cf_conn.create_stack(
StackName="test_stack", TemplateBody=json.dumps(template),
)
logs_conn = boto3.client("logs", region_name="us-west-2")
log_group = logs_conn.describe_log_groups()["logGroups"][0]
log_group["logGroupName"].should.equal("some-log-group")

View File

@ -3431,13 +3431,18 @@ def test_update_supports_list_append():
)
# Update item using list_append expression
client.update_item(
updated_item = client.update_item(
TableName="TestTable",
Key={"SHA256": {"S": "sha-of-file"}},
UpdateExpression="SET crontab = list_append(crontab, :i)",
ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"crontab": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}
)
# Verify item is appended to the existing list
result = client.get_item(
TableName="TestTable", Key={"SHA256": {"S": "sha-of-file"}}
@ -3470,15 +3475,19 @@ def test_update_supports_nested_list_append():
)
# Update item using list_append expression
client.update_item(
updated_item = client.update_item(
TableName="TestTable",
Key={"id": {"S": "nested_list_append"}},
UpdateExpression="SET a.#b = list_append(a.#b, :i)",
ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
ExpressionAttributeNames={"#b": "b"},
ReturnValues="UPDATED_NEW",
)
# Verify item is appended to the existing list
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"a": {"M": {"b": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}}}
)
result = client.get_item(
TableName="TestTable", Key={"id": {"S": "nested_list_append"}}
)["Item"]
@ -3510,14 +3519,19 @@ def test_update_supports_multiple_levels_nested_list_append():
)
# Update item using list_append expression
client.update_item(
updated_item = client.update_item(
TableName="TestTable",
Key={"id": {"S": "nested_list_append"}},
UpdateExpression="SET a.#b.c = list_append(a.#b.#c, :i)",
ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
ExpressionAttributeNames={"#b": "b", "#c": "c"},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"a": {"M": {"b": {"M": {"c": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}}}}}
)
# Verify item is appended to the existing list
result = client.get_item(
TableName="TestTable", Key={"id": {"S": "nested_list_append"}}
@ -3551,14 +3565,19 @@ def test_update_supports_nested_list_append_onto_another_list():
)
# Update item using list_append expression
client.update_item(
updated_item = client.update_item(
TableName="TestTable",
Key={"id": {"S": "list_append_another"}},
UpdateExpression="SET a.#c = list_append(a.#b, :i)",
ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
ExpressionAttributeNames={"#b": "b", "#c": "c"},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"a": {"M": {"c": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}}}
)
# Verify item is appended to the existing list
result = client.get_item(
TableName="TestTable", Key={"id": {"S": "list_append_another"}}
@ -3601,13 +3620,18 @@ def test_update_supports_list_append_maps():
)
# Update item using list_append expression
client.update_item(
updated_item = client.update_item(
TableName="TestTable",
Key={"id": {"S": "nested_list_append"}, "rid": {"S": "range_key"}},
UpdateExpression="SET a = list_append(a, :i)",
ExpressionAttributeValues={":i": {"L": [{"M": {"b": {"S": "bar2"}}}]}},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"a": {"L": [{"M": {"b": {"S": "bar1"}}}, {"M": {"b": {"S": "bar2"}}}]}}
)
# Verify item is appended to the existing list
result = client.query(
TableName="TestTable",
@ -3643,11 +3667,18 @@ def test_update_supports_list_append_with_nested_if_not_exists_operation():
table = dynamo.Table(table_name)
table.put_item(Item={"Id": "item-id", "nest1": {"nest2": {}}})
table.update_item(
updated_item = table.update_item(
Key={"Id": "item-id"},
UpdateExpression="SET nest1.nest2.event_history = list_append(if_not_exists(nest1.nest2.event_history, :empty_list), :new_value)",
ExpressionAttributeValues={":empty_list": [], ":new_value": ["some_value"]},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"nest1": {"nest2": {"event_history": ["some_value"]}}}
)
table.get_item(Key={"Id": "item-id"})["Item"].should.equal(
{"Id": "item-id", "nest1": {"nest2": {"event_history": ["some_value"]}}}
)
@ -3668,11 +3699,18 @@ def test_update_supports_list_append_with_nested_if_not_exists_operation_and_pro
table = dynamo.Table(table_name)
table.put_item(Item={"Id": "item-id", "event_history": ["other_value"]})
table.update_item(
updated_item = table.update_item(
Key={"Id": "item-id"},
UpdateExpression="SET event_history = list_append(if_not_exists(event_history, :empty_list), :new_value)",
ExpressionAttributeValues={":empty_list": [], ":new_value": ["some_value"]},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal(
{"event_history": ["other_value", "some_value"]}
)
table.get_item(Key={"Id": "item-id"})["Item"].should.equal(
{"Id": "item-id", "event_history": ["other_value", "some_value"]}
)
@ -3759,11 +3797,16 @@ def test_update_nested_item_if_original_value_is_none():
)
table = dynamo.Table("origin-rbu-dev")
table.put_item(Item={"job_id": "a", "job_details": {"job_name": None}})
table.update_item(
updated_item = table.update_item(
Key={"job_id": "a"},
UpdateExpression="SET job_details.job_name = :output",
ExpressionAttributeValues={":output": "updated"},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal({"job_details": {"job_name": "updated"}})
table.scan()["Items"][0]["job_details"]["job_name"].should.equal("updated")
@ -3779,11 +3822,16 @@ def test_allow_update_to_item_with_different_type():
table = dynamo.Table("origin-rbu-dev")
table.put_item(Item={"job_id": "a", "job_details": {"job_name": {"nested": "yes"}}})
table.put_item(Item={"job_id": "b", "job_details": {"job_name": {"nested": "yes"}}})
table.update_item(
updated_item = table.update_item(
Key={"job_id": "a"},
UpdateExpression="SET job_details.job_name = :output",
ExpressionAttributeValues={":output": "updated"},
ReturnValues="UPDATED_NEW",
)
# Verify updated item is correct
updated_item["Attributes"].should.equal({"job_details": {"job_name": "updated"}})
table.get_item(Key={"job_id": "a"})["Item"]["job_details"][
"job_name"
].should.be.equal("updated")
@ -4026,3 +4074,61 @@ def test_valid_transact_get_items():
"Table": {"CapacityUnits": 2.0, "ReadCapacityUnits": 2.0,},
}
)
@mock_dynamodb2
def test_gsi_verify_negative_number_order():
table_schema = {
"KeySchema": [{"AttributeName": "partitionKey", "KeyType": "HASH"}],
"GlobalSecondaryIndexes": [
{
"IndexName": "GSI-K1",
"KeySchema": [
{"AttributeName": "gsiK1PartitionKey", "KeyType": "HASH"},
{"AttributeName": "gsiK1SortKey", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "KEYS_ONLY",},
}
],
"AttributeDefinitions": [
{"AttributeName": "partitionKey", "AttributeType": "S"},
{"AttributeName": "gsiK1PartitionKey", "AttributeType": "S"},
{"AttributeName": "gsiK1SortKey", "AttributeType": "N"},
],
}
item1 = {
"partitionKey": "pk-1",
"gsiK1PartitionKey": "gsi-k1",
"gsiK1SortKey": Decimal("-0.6"),
}
item2 = {
"partitionKey": "pk-2",
"gsiK1PartitionKey": "gsi-k1",
"gsiK1SortKey": Decimal("-0.7"),
}
item3 = {
"partitionKey": "pk-3",
"gsiK1PartitionKey": "gsi-k1",
"gsiK1SortKey": Decimal("0.7"),
}
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
dynamodb.create_table(
TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema
)
table = dynamodb.Table("test-table")
table.put_item(Item=item3)
table.put_item(Item=item1)
table.put_item(Item=item2)
resp = table.query(
KeyConditionExpression=Key("gsiK1PartitionKey").eq("gsi-k1"),
IndexName="GSI-K1",
)
# Items should be ordered with the lowest number first
[float(item["gsiK1SortKey"]) for item in resp["Items"]].should.equal(
[-0.7, -0.6, 0.7]
)

View File

@ -574,6 +574,7 @@ def test_create_with_global_indexes():
"ReadCapacityUnits": 6,
"WriteCapacityUnits": 1,
},
"IndexStatus": "ACTIVE",
}
]
)

View File

@ -214,3 +214,16 @@ def test_send_raw_email_without_source_or_from():
kwargs = dict(RawMessage={"Data": message.as_string()})
conn.send_raw_email.when.called_with(**kwargs).should.throw(ClientError)
@mock_ses
def test_send_email_notification_with_encoded_sender():
sender = "Foo <foo@bar.baz>"
conn = boto3.client("ses", region_name="us-east-1")
conn.verify_email_identity(EmailAddress=sender)
response = conn.send_email(
Source=sender,
Destination={"ToAddresses": ["your.friend@hotmail.com"]},
Message={"Subject": {"Data": "hi",}, "Body": {"Text": {"Data": "there",}}},
)
response["ResponseMetadata"]["HTTPStatusCode"].should.equal(200)