Repository: getmoto/moto

Issue:
S3 ListParts API does not respect MaxParts
Hello,

I *think* there may be an issue with the `MaxParts` argument of `s3.list_parts()`. This pseudo-code:
```python
s3.list_parts(
    Bucket=bucket,
    Key=key,
    UploadId=multiPartUploadId,
    PartNumberMarker=0,
    MaxParts=2,
)
```

Should result in only 2 parts.

For the next snippets, I uploaded 4 parts of a file and tried to get uploaded parts by batch of 2.

Enabling DEBUG logging shows:
```
endpoint       D Sending http request: <AWSPreparedRequest stream_output=False, method=GET, url=URL?uploadId=UID&part-number-marker=0&max-parts=2, headers={...}>
```

But the answer is not what I was waiting. In fact, the `MaxParts` had no effect:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <PartNumberMarker>1</PartNumberMarker>
    <NextPartNumberMarker>4</NextPartNumberMarker>
    <MaxParts>4</MaxParts>
    <IsTruncated>false</IsTruncated>
<ListPartsResult>
```

The same code in a real environment works as expected.


Discussion:
After having read the source code, it seems this is not implemented, all parts are always returned. I will provide a PR soon if you are OK.

Resolving patch:
diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py
--- a/moto/s3/exceptions.py
+++ b/moto/s3/exceptions.py
@@ -237,6 +237,16 @@ def __init__(self):
         )
 
 
+class InvalidMaxPartArgument(S3ClientError):
+    code = 400
+
+    def __init__(self, arg, min_val, max_val):
+        error = "Argument {} must be an integer between {} and {}".format(
+            arg, min_val, max_val
+        )
+        super(InvalidMaxPartArgument, self).__init__("InvalidArgument", error)
+
+
 class InvalidNotificationARN(S3ClientError):
     code = 400
 
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -369,9 +369,11 @@ def set_part(self, part_id, value):
             insort(self.partlist, part_id)
         return key
 
-    def list_parts(self):
+    def list_parts(self, part_number_marker, max_parts):
         for part_id in self.partlist:
-            yield self.parts[part_id]
+            part = self.parts[part_id]
+            if part_number_marker <= part.name < part_number_marker + max_parts:
+                yield part
 
 
 class FakeGrantee(BaseModel):
@@ -1712,6 +1714,20 @@ def abort_multipart_upload(self, bucket_name, multipart_id):
             raise NoSuchUpload(upload_id=multipart_id)
         del bucket.multiparts[multipart_id]
 
+    def list_multipart(
+        self, bucket_name, multipart_id, part_number_marker=0, max_parts=1000
+    ):
+        bucket = self.get_bucket(bucket_name)
+        if multipart_id not in bucket.multiparts:
+            raise NoSuchUpload(upload_id=multipart_id)
+        return list(
+            bucket.multiparts[multipart_id].list_parts(part_number_marker, max_parts)
+        )
+
+    def is_truncated(self, bucket_name, multipart_id, next_part_number_marker):
+        bucket = self.get_bucket(bucket_name)
+        return len(bucket.multiparts[multipart_id].parts) >= next_part_number_marker
+
     def create_multipart_upload(self, bucket_name, key_name, metadata, storage_type):
         multipart = FakeMultipart(key_name, metadata)
         multipart.storage = storage_type
@@ -1728,12 +1744,6 @@ def complete_multipart_upload(self, bucket_name, multipart_id, body):
             del bucket.multiparts[multipart_id]
         return multipart, value, etag
 
-    def list_multipart(self, bucket_name, multipart_id):
-        bucket = self.get_bucket(bucket_name)
-        if multipart_id not in bucket.multiparts:
-            raise NoSuchUpload(upload_id=multipart_id)
-        return list(bucket.multiparts[multipart_id].list_parts())
-
     def get_all_multiparts(self, bucket_name):
         bucket = self.get_bucket(bucket_name)
         return bucket.multiparts
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -45,6 +45,7 @@
     MissingBucket,
     MissingKey,
     MissingVersion,
+    InvalidMaxPartArgument,
     InvalidPartOrder,
     MalformedXML,
     MalformedACLError,
@@ -1233,7 +1234,28 @@ def _key_response_get(self, bucket_name, query, key_name, headers):
         response_headers = {}
         if query.get("uploadId"):
             upload_id = query["uploadId"][0]
-            parts = self.backend.list_multipart(bucket_name, upload_id)
+
+            # 0 <= PartNumberMarker <= 2,147,483,647
+            part_number_marker = int(query.get("part-number-marker", [0])[0])
+            if not (0 <= part_number_marker <= 2147483647):
+                raise InvalidMaxPartArgument("part-number-marker", 0, 2147483647)
+
+            # 0 <= MaxParts <= 2,147,483,647 (default is 1,000)
+            max_parts = int(query.get("max-parts", [1000])[0])
+            if not (0 <= max_parts <= 2147483647):
+                raise InvalidMaxPartArgument("max-parts", 0, 2147483647)
+
+            parts = self.backend.list_multipart(
+                bucket_name,
+                upload_id,
+                part_number_marker=part_number_marker,
+                max_parts=max_parts,
+            )
+            next_part_number_marker = parts[-1].name + 1 if parts else 0
+            is_truncated = parts and self.backend.is_truncated(
+                bucket_name, upload_id, next_part_number_marker
+            )
+
             template = self.response_template(S3_MULTIPART_LIST_RESPONSE)
             return (
                 200,
@@ -1242,8 +1264,11 @@ def _key_response_get(self, bucket_name, query, key_name, headers):
                     bucket_name=bucket_name,
                     key_name=key_name,
                     upload_id=upload_id,
-                    count=len(parts),
+                    is_truncated=str(is_truncated).lower(),
+                    max_parts=max_parts,
+                    next_part_number_marker=next_part_number_marker,
                     parts=parts,
+                    part_number_marker=part_number_marker,
                 ),
             )
         version_id = query.get("versionId", [None])[0]
@@ -2351,10 +2376,10 @@ def _invalid_headers(self, url, headers):
     <ID>75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a</ID>
     <DisplayName>webfile</DisplayName>
   </Owner>
-  <PartNumberMarker>1</PartNumberMarker>
-  <NextPartNumberMarker>{{ count }}</NextPartNumberMarker>
-  <MaxParts>{{ count }}</MaxParts>
-  <IsTruncated>false</IsTruncated>
+  <PartNumberMarker>{{ part_number_marker }}</PartNumberMarker>
+  <NextPartNumberMarker>{{ next_part_number_marker }}</NextPartNumberMarker>
+  <MaxParts>{{ max_parts }}</MaxParts>
+  <IsTruncated>{{ is_truncated }}</IsTruncated>
   {% for part in parts %}
   <Part>
     <PartNumber>{{ part.name }}</PartNumber>


Test patch:
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -2739,6 +2739,141 @@ def test_boto3_multipart_version():
     response["VersionId"].should.should_not.be.none
 
 
+@mock_s3
+def test_boto3_multipart_list_parts_invalid_argument():
+    s3 = boto3.client("s3", region_name="us-east-1")
+    s3.create_bucket(Bucket="mybucket")
+
+    mpu = s3.create_multipart_upload(Bucket="mybucket", Key="the-key")
+    mpu_id = mpu["UploadId"]
+
+    def get_parts(**kwarg):
+        s3.list_parts(Bucket="mybucket", Key="the-key", UploadId=mpu_id, **kwarg)
+
+    for value in [-42, 2147483647 + 42]:
+        with pytest.raises(ClientError) as err:
+            get_parts(**{"MaxParts": value})
+        e = err.value.response["Error"]
+        e["Code"].should.equal("InvalidArgument")
+        e["Message"].should.equal(
+            "Argument max-parts must be an integer between 0 and 2147483647"
+        )
+
+        with pytest.raises(ClientError) as err:
+            get_parts(**{"PartNumberMarker": value})
+        e = err.value.response["Error"]
+        e["Code"].should.equal("InvalidArgument")
+        e["Message"].should.equal(
+            "Argument part-number-marker must be an integer between 0 and 2147483647"
+        )
+
+
+@mock_s3
+@reduced_min_part_size
+def test_boto3_multipart_list_parts():
+    s3 = boto3.client("s3", region_name="us-east-1")
+    s3.create_bucket(Bucket="mybucket")
+
+    mpu = s3.create_multipart_upload(Bucket="mybucket", Key="the-key")
+    mpu_id = mpu["UploadId"]
+
+    parts = []
+    n_parts = 10
+
+    def get_parts_all(i):
+        # Get uploaded parts using default values
+        uploaded_parts = []
+
+        uploaded = s3.list_parts(Bucket="mybucket", Key="the-key", UploadId=mpu_id,)
+
+        assert uploaded["PartNumberMarker"] == 0
+
+        # Parts content check
+        if i > 0:
+            for part in uploaded["Parts"]:
+                uploaded_parts.append(
+                    {"ETag": part["ETag"], "PartNumber": part["PartNumber"]}
+                )
+            assert uploaded_parts == parts
+
+            next_part_number_marker = uploaded["Parts"][-1]["PartNumber"] + 1
+        else:
+            next_part_number_marker = 0
+
+        assert uploaded["NextPartNumberMarker"] == next_part_number_marker
+
+        assert not uploaded["IsTruncated"]
+
+    def get_parts_by_batch(i):
+        # Get uploaded parts by batch of 2
+        part_number_marker = 0
+        uploaded_parts = []
+
+        while "there are parts":
+            uploaded = s3.list_parts(
+                Bucket="mybucket",
+                Key="the-key",
+                UploadId=mpu_id,
+                PartNumberMarker=part_number_marker,
+                MaxParts=2,
+            )
+
+            assert uploaded["PartNumberMarker"] == part_number_marker
+
+            if i > 0:
+                # We should received maximum 2 parts
+                assert len(uploaded["Parts"]) <= 2
+
+                # Store parts content for the final check
+                for part in uploaded["Parts"]:
+                    uploaded_parts.append(
+                        {"ETag": part["ETag"], "PartNumber": part["PartNumber"]}
+                    )
+
+            # No more parts, get out the loop
+            if not uploaded["IsTruncated"]:
+                break
+
+            # Next parts batch will start with that number
+            part_number_marker = uploaded["NextPartNumberMarker"]
+            assert part_number_marker == i + 1 if len(parts) > i else i
+
+        # Final check: we received all uploaded parts
+        assert uploaded_parts == parts
+
+    # Check ListParts API parameters when no part was uploaded
+    get_parts_all(0)
+    get_parts_by_batch(0)
+
+    for i in range(1, n_parts + 1):
+        part_size = REDUCED_PART_SIZE + i
+        body = b"1" * part_size
+        part = s3.upload_part(
+            Bucket="mybucket",
+            Key="the-key",
+            PartNumber=i,
+            UploadId=mpu_id,
+            Body=body,
+            ContentLength=len(body),
+        )
+        parts.append({"PartNumber": i, "ETag": part["ETag"]})
+
+        # Check ListParts API parameters while there are uploaded parts
+        get_parts_all(i)
+        get_parts_by_batch(i)
+
+    # Check ListParts API parameters when all parts were uploaded
+    get_parts_all(11)
+    get_parts_by_batch(11)
+
+    s3.complete_multipart_upload(
+        Bucket="mybucket",
+        Key="the-key",
+        UploadId=mpu_id,
+        MultipartUpload={"Parts": parts},
+    )
+
+
 @mock_s3
 @reduced_min_part_size
 def test_boto3_multipart_part_size():