|
5 | 5 | import boto3 |
6 | 6 | import pytest |
7 | 7 | from botocore import stub |
| 8 | +from botocore.exceptions import ClientError |
8 | 9 |
|
| 10 | +from aws_lambda_powertools.utilities.streaming import S3Object |
9 | 11 | from aws_lambda_powertools.utilities.streaming._s3_seekable_io import _S3SeekableIO |
10 | 12 | from aws_lambda_powertools.utilities.streaming.compat import PowertoolsStreamingBody |
11 | 13 |
|
@@ -134,17 +136,93 @@ def test_readlines(s3_seekable_obj, s3_client_stub): |
134 | 136 | assert s3_seekable_obj.tell() == len(payload) |
135 | 137 |
|
136 | 138 |
|
137 | | -def test_closed(s3_seekable_obj, s3_client_stub): |
138 | | - payload = b"test" |
139 | | - streaming_body = PowertoolsStreamingBody(raw_stream=io.BytesIO(payload), content_length=len(payload)) |
| 139 | +def test_read_at_end_of_object_returns_empty_bytes(s3_seekable_obj, s3_client_stub): |
| 140 | + s3_client_stub.add_response("head_object", {"ContentLength": 4}) |
| 141 | + # S3 rejects a range that starts at the end of the object |
| 142 | + s3_client_stub.add_client_error( |
| 143 | + "get_object", |
| 144 | + service_error_code="InvalidRange", |
| 145 | + http_status_code=416, |
| 146 | + expected_params={"Bucket": s3_seekable_obj.bucket, "Key": s3_seekable_obj.key, "Range": "bytes=4-"}, |
| 147 | + ) |
140 | 148 |
|
141 | | - s3_client_stub.add_response( |
| 149 | + s3_seekable_obj.seek(0, io.SEEK_END) |
| 150 | + |
| 151 | + assert s3_seekable_obj.read() == b"" |
| 152 | + assert s3_seekable_obj.tell() == 4 |
| 153 | + |
| 154 | + |
| 155 | +def test_read_empty_object_returns_empty_bytes(s3_seekable_obj, s3_client_stub): |
| 156 | + # S3 rejects any range on an empty object |
| 157 | + s3_client_stub.add_client_error( |
142 | 158 | "get_object", |
143 | | - {"Body": streaming_body}, |
144 | | - {"Bucket": s3_seekable_obj.bucket, "Key": s3_seekable_obj.key, "Range": "bytes=0-"}, |
| 159 | + service_error_code="InvalidRange", |
| 160 | + http_status_code=416, |
| 161 | + expected_params={"Bucket": s3_seekable_obj.bucket, "Key": s3_seekable_obj.key, "Range": "bytes=0-"}, |
145 | 162 | ) |
146 | 163 |
|
| 164 | + assert s3_seekable_obj.read() == b"" |
| 165 | + assert list(s3_seekable_obj) == [] |
| 166 | + assert s3_seekable_obj.tell() == 0 |
| 167 | + |
| 168 | + |
| 169 | +def test_raw_stream_raises_other_client_errors(s3_seekable_obj, s3_client_stub): |
| 170 | + s3_client_stub.add_client_error("get_object", service_error_code="NoSuchKey", http_status_code=404) |
| 171 | + |
| 172 | + with pytest.raises(ClientError, match="NoSuchKey"): |
| 173 | + s3_seekable_obj.read() |
| 174 | + |
| 175 | + |
| 176 | +def test_closed(s3_seekable_obj, s3_client_stub): |
147 | 177 | s3_seekable_obj.close() |
| 178 | + |
| 179 | + assert s3_seekable_obj.closed is True |
| 180 | + # Closing an object that was never read must not open a stream just to close it |
| 181 | + s3_client_stub.assert_no_pending_responses() |
| 182 | + |
| 183 | + |
| 184 | +@pytest.mark.parametrize("stream_class", [_S3SeekableIO, S3Object]) |
| 185 | +@pytest.mark.parametrize("read_method", ["read", "readline", "readlines", "__next__"]) |
| 186 | +@pytest.mark.parametrize("initial_state", ["unread", "partially_read", "seeked", "empty"]) |
| 187 | +def test_reads_after_close_do_not_reopen_stream(s3_client, s3_client_stub, stream_class, read_method, initial_state): |
| 188 | + stream = stream_class(bucket="bucket", key="key", boto3_client=s3_client) |
| 189 | + expected_params = {"Bucket": "bucket", "Key": "key", "Range": "bytes=0-"} |
| 190 | + |
| 191 | + if initial_state == "empty": |
| 192 | + s3_client_stub.add_client_error( |
| 193 | + "get_object", |
| 194 | + service_error_code="InvalidRange", |
| 195 | + http_status_code=416, |
| 196 | + expected_params=expected_params, |
| 197 | + ) |
| 198 | + assert stream.read() == b"" |
| 199 | + elif initial_state != "unread": |
| 200 | + payload = b"hello\nworld" |
| 201 | + body = PowertoolsStreamingBody(raw_stream=io.BytesIO(payload), content_length=len(payload)) |
| 202 | + s3_client_stub.add_response("get_object", {"Body": body}, expected_params) |
| 203 | + assert stream.read(1) == b"h" |
| 204 | + if initial_state == "seeked": |
| 205 | + stream.seek(3) |
| 206 | + |
| 207 | + position = stream.tell() |
| 208 | + stream.close() |
| 209 | + stream.close() |
| 210 | + |
| 211 | + with pytest.raises(ValueError, match="I/O operation on closed file"): |
| 212 | + getattr(stream, read_method)() |
| 213 | + |
| 214 | + assert stream.closed is True |
| 215 | + assert stream.tell() == position |
| 216 | + # The stub has no queued responses, so any attempt to reopen the stream would fail the test. |
| 217 | + s3_client_stub.assert_no_pending_responses() |
| 218 | + |
| 219 | + |
| 220 | +def test_context_manager_at_end_of_object(s3_seekable_obj, s3_client_stub): |
| 221 | + s3_client_stub.add_response("head_object", {"ContentLength": 4}) |
| 222 | + |
| 223 | + with s3_seekable_obj as f: |
| 224 | + f.seek(0, io.SEEK_END) |
| 225 | + |
148 | 226 | assert s3_seekable_obj.closed is True |
149 | 227 |
|
150 | 228 |
|
|
0 commit comments