S3:list_object_versions() - Implement Delimiter + KeyMarker (#4413)

This commit is contained in:
Bert Blommers 2021-10-14 18:13:40 +00:00 committed by GitHub
parent 230e34748f
commit d916fd636f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 175 additions and 45 deletions

View File

@ -19,7 +19,11 @@ from bisect import insort
import pytz
from moto.core import ACCOUNT_ID, BaseBackend, BaseModel, CloudFormationModel
from moto.core.utils import iso_8601_datetime_without_milliseconds_s3, rfc_1123_datetime
from moto.core.utils import (
iso_8601_datetime_without_milliseconds_s3,
rfc_1123_datetime,
unix_time_millis,
)
from moto.cloudwatch.models import MetricDatum
from moto.utilities.tagging_service import TaggingService
from .exceptions import (
@ -1405,23 +1409,6 @@ class S3Backend(BaseBackend):
def get_bucket_encryption(self, bucket_name):
return self.get_bucket(bucket_name).encryption
def get_bucket_latest_versions(self, bucket_name):
versions = self.list_object_versions(bucket_name)
latest_modified_per_key = {}
latest_versions = {}
for version in versions:
name = version.name
last_modified = version.last_modified
version_id = version.version_id
latest_modified_per_key[name] = max(
last_modified, latest_modified_per_key.get(name, datetime.datetime.min)
)
if last_modified == latest_modified_per_key[name]:
latest_versions[name] = version_id
return latest_versions
def list_object_versions(
self,
bucket_name,
@ -1434,14 +1421,44 @@ class S3Backend(BaseBackend):
):
bucket = self.get_bucket(bucket_name)
if any((delimiter, key_marker, version_id_marker)):
raise NotImplementedError(
"Called get_bucket_versions with some of delimiter, encoding_type, key_marker, version_id_marker"
)
return itertools.chain(
*(l for key, l in bucket.keys.iterlists() if key.startswith(prefix))
common_prefixes = []
requested_versions = []
delete_markers = []
all_versions = itertools.chain(
*(copy.deepcopy(l) for key, l in bucket.keys.iterlists())
)
all_versions = list(all_versions)
# sort by name, revert last-modified-date
all_versions.sort(key=lambda r: (r.name, -unix_time_millis(r.last_modified)))
last_name = None
for version in all_versions:
name = version.name
# guaranteed to be sorted - so the first key with this name will be the latest
version.is_latest = name != last_name
if version.is_latest:
last_name = name
# Differentiate between FakeKey and FakeDeleteMarkers
if not isinstance(version, FakeKey):
delete_markers.append(version)
continue
# skip all keys that alphabetically come before keymarker
if key_marker and name < key_marker:
continue
# Filter for keys that start with prefix
if not name.startswith(prefix):
continue
# separate out all keys that contain delimiter
if delimiter and delimiter in name:
index = name.index(delimiter) + len(delimiter)
prefix_including_delimiter = name[0:index]
common_prefixes.append(prefix_including_delimiter)
continue
requested_versions.append(version)
common_prefixes = sorted(set(common_prefixes))
return requested_versions, common_prefixes, delete_markers
def get_bucket_policy(self, bucket_name):
return self.get_bucket(bucket_name).policy

View File

@ -11,7 +11,6 @@ from moto.core.utils import (
amzn_request_id,
str_to_rfc_1123_datetime,
py2_strip_unicode_keys,
unix_time_millis,
)
from urllib.parse import (
parse_qs,
@ -474,7 +473,11 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
version_id_marker = querystring.get("version-id-marker", [None])[0]
bucket = self.backend.get_bucket(bucket_name)
versions = self.backend.list_object_versions(
(
versions,
common_prefixes,
delete_markers,
) = self.backend.list_object_versions(
bucket_name,
delimiter=delimiter,
encoding_type=encoding_type,
@ -483,30 +486,21 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
version_id_marker=version_id_marker,
prefix=prefix,
)
latest_versions = self.backend.get_bucket_latest_versions(
bucket_name=bucket_name
)
key_list = []
delete_marker_list = []
for version in versions:
if isinstance(version, FakeKey):
key_list.append(version)
else:
delete_marker_list.append(version)
key_list = versions
template = self.response_template(S3_BUCKET_GET_VERSIONS)
key_list.sort(key=lambda r: (r.name, -unix_time_millis(r.last_modified)))
return (
200,
{},
template.render(
common_prefixes=common_prefixes,
key_list=key_list,
delete_marker_list=delete_marker_list,
latest_versions=latest_versions,
delete_marker_list=delete_markers,
bucket=bucket,
prefix=prefix,
max_keys=1000,
delimiter="",
delimiter=delimiter,
key_marker=key_marker,
is_truncated="false",
),
)
@ -2243,14 +2237,22 @@ S3_BUCKET_GET_VERSIONS = """<?xml version="1.0" encoding="UTF-8"?>
{% if prefix != None %}
<Prefix>{{ prefix }}</Prefix>
{% endif %}
<KeyMarker>{{ key_marker }}</KeyMarker>
{% if common_prefixes %}
{% for prefix in common_prefixes %}
<CommonPrefixes>
<Prefix>{{ prefix }}</Prefix>
</CommonPrefixes>
{% endfor %}
{% endif %}
<Delimiter>{{ delimiter }}</Delimiter>
<KeyMarker>{{ key_marker or "" }}</KeyMarker>
<MaxKeys>{{ max_keys }}</MaxKeys>
<IsTruncated>{{ is_truncated }}</IsTruncated>
{% for key in key_list %}
<Version>
<Key>{{ key.name }}</Key>
<VersionId>{% if key.version_id is none %}null{% else %}{{ key.version_id }}{% endif %}</VersionId>
<IsLatest>{% if latest_versions[key.name] == key.version_id %}true{% else %}false{% endif %}</IsLatest>
<IsLatest>{{ 'true' if key.is_latest else 'false' }}</IsLatest>
<LastModified>{{ key.last_modified_ISO8601 }}</LastModified>
<ETag>{{ key.etag }}</ETag>
<Size>{{ key.size }}</Size>
@ -2265,7 +2267,7 @@ S3_BUCKET_GET_VERSIONS = """<?xml version="1.0" encoding="UTF-8"?>
<DeleteMarker>
<Key>{{ marker.name }}</Key>
<VersionId>{{ marker.version_id }}</VersionId>
<IsLatest>{% if latest_versions[marker.name] == marker.version_id %}true{% else %}false{% endif %}</IsLatest>
<IsLatest>{{ 'true' if marker.is_latest else 'false' }}</IsLatest>
<LastModified>{{ marker.last_modified_ISO8601 }}</LastModified>
<Owner>
<ID>75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a</ID>

View File

@ -36,6 +36,7 @@ from moto import settings, mock_s3, mock_s3_deprecated, mock_config
import moto.s3.models as s3model
from moto.core.exceptions import InvalidNextTokenException
from moto.settings import get_s3_default_key_buffer_size, S3_UPLOAD_PART_MIN_SIZE
from uuid import uuid4
if settings.TEST_SERVER_MODE:
REDUCED_PART_SIZE = S3_UPLOAD_PART_MIN_SIZE
@ -4879,7 +4880,7 @@ def test_boto3_get_object_tagging():
@mock_s3
def test_boto3_list_object_versions():
s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
bucket_name = "mybucket"
bucket_name = "000" + str(uuid4())
key = "key-with-versions"
s3.create_bucket(Bucket=bucket_name)
s3.put_bucket_versioning(
@ -4902,6 +4903,116 @@ def test_boto3_list_object_versions():
response["Body"].read().should.equal(items[-1])
@mock_s3
def test_boto3_list_object_versions_with_delimiter():
s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
bucket_name = "000" + str(uuid4())
s3.create_bucket(Bucket=bucket_name)
s3.put_bucket_versioning(
Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"}
)
for key_index in list(range(1, 5)) + list(range(10, 14)):
for version_index in range(1, 4):
body = f"data-{version_index}".encode("UTF-8")
s3.put_object(
Bucket=bucket_name, Key=f"key{key_index}-with-data", Body=body
)
s3.put_object(
Bucket=bucket_name, Key=f"key{key_index}-without-data", Body=b""
)
response = s3.list_object_versions(Bucket=bucket_name)
# All object versions should be returned
len(response["Versions"]).should.equal(
48
) # 8 keys * 2 (one with, one without) * 3 versions per key
# Use start of key as delimiter
response = s3.list_object_versions(Bucket=bucket_name, Delimiter="key1")
response.should.have.key("CommonPrefixes").equal([{"Prefix": "key1"}])
response.should.have.key("Delimiter").equal("key1")
# 3 keys that do not contain the phrase 'key1' (key2, key3, key4) * * 2 * 3
response.should.have.key("Versions").length_of(18)
# Use in-between key as delimiter
response = s3.list_object_versions(Bucket=bucket_name, Delimiter="-with-")
response.should.have.key("CommonPrefixes").equal(
[
{"Prefix": "key1-with-"},
{"Prefix": "key10-with-"},
{"Prefix": "key11-with-"},
{"Prefix": "key12-with-"},
{"Prefix": "key13-with-"},
{"Prefix": "key2-with-"},
{"Prefix": "key3-with-"},
{"Prefix": "key4-with-"},
]
)
response.should.have.key("Delimiter").equal("-with-")
# key(1/10/11/12/13)-without, key(2/3/4)-without
response.should.have.key("Versions").length_of(8 * 1 * 3)
# Use in-between key as delimiter
response = s3.list_object_versions(Bucket=bucket_name, Delimiter="1-with-")
response.should.have.key("CommonPrefixes").equal(
[{"Prefix": "key1-with-"}, {"Prefix": "key11-with-"}]
)
response.should.have.key("Delimiter").equal("1-with-")
response.should.have.key("Versions").length_of(42)
all_keys = set([v["Key"] for v in response["Versions"]])
all_keys.should.contain("key1-without-data")
all_keys.shouldnt.contain("key1-with-data")
all_keys.should.contain("key4-with-data")
all_keys.should.contain("key4-without-data")
# Use in-between key as delimiter + prefix
response = s3.list_object_versions(
Bucket=bucket_name, Prefix="key1", Delimiter="with-"
)
response.should.have.key("CommonPrefixes").equal(
[
{"Prefix": "key1-with-"},
{"Prefix": "key10-with-"},
{"Prefix": "key11-with-"},
{"Prefix": "key12-with-"},
{"Prefix": "key13-with-"},
]
)
response.should.have.key("Delimiter").equal("with-")
response.should.have.key("KeyMarker").equal("")
response.shouldnt.have.key("NextKeyMarker")
response.should.have.key("Versions").length_of(15)
all_keys = set([v["Key"] for v in response["Versions"]])
all_keys.should.equal(
{
"key1-without-data",
"key10-without-data",
"key11-without-data",
"key13-without-data",
"key12-without-data",
}
)
# Start at KeyMarker, and filter using Prefix+Delimiter for all subsequent keys
response = s3.list_object_versions(
Bucket=bucket_name, Prefix="key1", Delimiter="with-", KeyMarker="key11"
)
response.should.have.key("CommonPrefixes").equal(
[
{"Prefix": "key11-with-"},
{"Prefix": "key12-with-"},
{"Prefix": "key13-with-"},
]
)
response.should.have.key("Delimiter").equal("with-")
response.should.have.key("KeyMarker").equal("key11")
response.shouldnt.have.key("NextKeyMarker")
response.should.have.key("Versions").length_of(9)
all_keys = set([v["Key"] for v in response["Versions"]])
all_keys.should.equal(
{"key11-without-data", "key12-without-data", "key13-without-data"}
)
@mock_s3
def test_boto3_list_object_versions_with_versioning_disabled():
s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)