Skip to content

Commit 5a0a5cb

Browse files
Merge pull request #1091 from pyathena-dev/refactor/1086-get-put-object
Move object GET and PUT requests into S3Core
2 parents 87a5b79 + 6bc2686 commit 5a0a5cb

6 files changed

Lines changed: 360 additions & 254 deletions

File tree

‎docs/filesystem.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ directories below the bucket level) and is always a no-op.
300300
## Typed S3 operations
301301

302302
`S3FileSystem.core` is an `S3Core`, the typed operations that the filesystem sends
303-
its listing, lookup, delete, multipart upload and copy requests with. It can also be
303+
its listing, lookup, read, write, delete, multipart upload and copy requests with. It can also be
304304
built on a boto3 S3 client. Each operation sends one request (one per page for the
305305
iterators and `list_object_annotations()`); `plan_multipart_copy()` and
306306
`copy_object_annotation()`, described below, send several. The requests are sent with
@@ -326,6 +326,18 @@ for page in core.list_objects("YOUR_S3_BUCKET", prefix="path/to/", delimiter="/"
326326

327327
`list_object_versions()` and `list_buckets()` return pages in the same way.
328328

329+
`get_object()` reads an object, a version of it, or a byte range and returns the bytes.
330+
A range has an exclusive end; `None` as the end reads to the end of the object, and a
331+
negative start without an end reads the last bytes. The response body is closed after
332+
the read; a failure to read it raises the botocore exception and is not retried.
333+
`put_object()` writes bytes and returns an `S3PutObject`.
334+
335+
```python
336+
data = core.get_object(S3Path.parse("s3://YOUR_S3_BUCKET/path/to/object"), (0, 1024))
337+
result = core.put_object(S3Path("YOUR_S3_BUCKET", "path/to/copy"), data)
338+
print(result.etag, result.version_id)
339+
```
340+
329341
`delete_objects()` deletes an `S3DeleteBatch`, the objects of one bucket that one
330342
DeleteObjects request accepts. `S3DeleteBatch.from_paths()` groups paths into batches
331343
of up to 1,000 objects per bucket. The objects that S3 could not delete are in the

‎pyathena/filesystem/s3.py‎

Lines changed: 15 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
S3Object,
4545
S3ObjectType,
4646
S3ObjectVersion,
47-
S3PutObject,
4847
S3StorageClass,
4948
)
5049
from pyathena.filesystem.s3_path import S3Path
@@ -1403,7 +1402,9 @@ def touch(self, path: str, truncate: bool = True, **kwargs) -> dict[str, Any]:
14031402
if not s3_path.key:
14041403
raise ValueError("Cannot touch the bucket.")
14051404

1406-
object_ = self._put_object(bucket=s3_path.bucket, key=s3_path.key, body=None, **kwargs)
1405+
# The empty body is passed, so that a body keyword argument raises
1406+
# TypeError.
1407+
object_ = self.core.put_object(s3_path, None, **kwargs)
14071408
self.invalidate_cache(path)
14081409
return object_.to_dict()
14091410

@@ -1873,7 +1874,7 @@ def pipe_file(
18731874
# buffered path does.
18741875
value = bytes(value)
18751876

1876-
self._put_object(bucket=s3_path.bucket, key=s3_path.key, body=value, **request_kwargs)
1877+
self.core.put_object(s3_path, value, **request_kwargs)
18771878
self.invalidate_cache(path)
18781879

18791880
def _finish_multipart_upload(
@@ -2012,13 +2013,7 @@ def cat_file(
20122013
return b""
20132014
ranges = (start, end)
20142015
try:
2015-
return self._get_object(
2016-
bucket=s3_path.bucket,
2017-
key=s3_path.key,
2018-
ranges=ranges,
2019-
version_id=version_id,
2020-
**kwargs,
2021-
)[1]
2016+
return self.core.get_object(s3_path.with_version_id(version_id), ranges, **kwargs)
20222017
except OSError as e:
20232018
if (
20242019
ranges
@@ -2869,69 +2864,6 @@ def _open(
28692864
**kwargs,
28702865
)
28712866

2872-
def _get_object(
2873-
self,
2874-
bucket: str,
2875-
key: str,
2876-
ranges: tuple[int, int | None] | None = None,
2877-
version_id: str | None = None,
2878-
**kwargs,
2879-
) -> tuple[int, bytes]:
2880-
"""Read an object or a byte range of it with GetObject.
2881-
2882-
Args:
2883-
bucket: The bucket name.
2884-
key: The object key.
2885-
ranges: The ``(start, end)`` byte range to read, with an exclusive
2886-
end or ``None`` to read to the end of the object (the last
2887-
``-start`` bytes for a negative start), or ``None`` to read
2888-
the whole object.
2889-
version_id: The version ID to read, or ``None`` for the latest.
2890-
**kwargs: Additional parameters passed to the GetObject API.
2891-
2892-
Returns:
2893-
Tuple of the start of the range as given (0 for the whole
2894-
object) and the bytes read.
2895-
2896-
Raises:
2897-
ValueError: If the range is empty. S3 ignores a range whose last
2898-
byte precedes its first byte and returns the whole object.
2899-
"""
2900-
request = {"Bucket": bucket, "Key": key}
2901-
if ranges:
2902-
if ranges[1] is not None and ranges[0] >= ranges[1]:
2903-
raise ValueError(f"Invalid empty range: {ranges}.")
2904-
range_ = S3File._format_ranges(ranges)
2905-
request.update({"Range": range_})
2906-
else:
2907-
ranges = (0, 0)
2908-
range_ = "bytes=0-"
2909-
if version_id:
2910-
request.update({"VersionId": version_id})
2911-
2912-
_logger.debug(f"Get object: {S3Path(bucket, key, version_id).uri} range={range_}")
2913-
response = self._call(
2914-
self._client.get_object,
2915-
# The fields of the request take precedence over inherited
2916-
# parameters of the same name.
2917-
**{**kwargs, **request},
2918-
)
2919-
return ranges[0], cast(bytes, response["Body"].read())
2920-
2921-
def _put_object(self, bucket: str, key: str, body: bytes | None, **kwargs) -> S3PutObject:
2922-
request: dict[str, Any] = {"Bucket": bucket, "Key": key}
2923-
if body:
2924-
request.update({"Body": body})
2925-
2926-
_logger.debug(f"Put object: s3://{bucket}/{key}")
2927-
response = self._call(
2928-
self._client.put_object,
2929-
# The fields of the request take precedence over inherited
2930-
# parameters of the same name.
2931-
**{**kwargs, **request},
2932-
)
2933-
return S3PutObject(response)
2934-
29352867
def _call(self, method: str | Callable[..., Any], **kwargs) -> dict[str, Any]:
29362868
"""Send a request with the core (see :meth:`S3Core.call`).
29372869
@@ -3364,11 +3296,8 @@ def commit(self) -> None:
33643296
# Upload files smaller than block size.
33653297
self.buffer.seek(0)
33663298
data = self.buffer.read()
3367-
self.fs._put_object(
3368-
bucket=self.bucket,
3369-
key=self.key,
3370-
body=data,
3371-
**self._get_request_kwargs("put_object"),
3299+
self.fs.core.put_object(
3300+
S3Path(self.bucket, self.key), data, **self._get_request_kwargs("put_object")
33723301
)
33733302
else:
33743303
if not self.multipart_upload:
@@ -3488,45 +3417,18 @@ def _fetch_range(self, start: int, end: int) -> bytes:
34883417
ranges = self._get_ranges(
34893418
start, end, max_workers=self.max_workers, worker_block_size=self.blocksize
34903419
)
3420+
path = S3Path(self.bucket, self.key, self.version_id)
3421+
request_kwargs = self._get_request_kwargs("get_object")
34913422
if len(ranges) > 1:
34923423
futures = [
3493-
self._executor.submit(
3494-
self.fs._get_object,
3495-
bucket=self.bucket,
3496-
key=self.key,
3497-
ranges=r,
3498-
version_id=self.version_id,
3499-
**self._get_request_kwargs("get_object"),
3500-
)
3424+
self._executor.submit(self.fs.core.get_object, path, r, **request_kwargs)
35013425
for r in ranges
35023426
]
3503-
object_ = self._merge_objects([f.result() for f in as_completed(futures)])
3504-
else:
3505-
object_ = self.fs._get_object(
3506-
self.bucket,
3507-
self.key,
3508-
ranges[0],
3509-
self.version_id,
3510-
**self._get_request_kwargs("get_object"),
3511-
)[1]
3512-
return object_
3513-
3514-
@staticmethod
3515-
def _format_ranges(ranges: tuple[int, int | None]) -> str:
3516-
"""Format a byte range as the value of an HTTP ``Range`` header.
3517-
3518-
Args:
3519-
ranges: The ``(start, end)`` byte range, with an exclusive end or
3520-
``None`` for the end of the object. A negative start with no
3521-
end selects the last ``-start`` bytes.
3522-
3523-
Returns:
3524-
The range, such as ``bytes=0-99``, ``bytes=100-`` or ``bytes=-8``.
3525-
"""
3526-
start, end = ranges
3527-
if end is None:
3528-
return f"bytes={start}" if start < 0 else f"bytes={start}-"
3529-
return f"bytes={start}-{end - 1}"
3427+
for future in as_completed(futures):
3428+
# The failure of the first read to finish is raised.
3429+
future.result()
3430+
return b"".join(f.result() for f in futures)
3431+
return self.fs.core.get_object(path, ranges[0], **request_kwargs)
35303432

35313433
@staticmethod
35323434
def _get_ranges(
@@ -3548,8 +3450,3 @@ def _get_ranges(
35483450
else:
35493451
ranges.append((start, end))
35503452
return ranges
3551-
3552-
@staticmethod
3553-
def _merge_objects(objects: list[tuple[int, bytes]]) -> bytes:
3554-
objects.sort(key=lambda x: x[0])
3555-
return b"".join([obj for start, obj in objects])

‎pyathena/filesystem/s3_core.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
import contextlib
1213
import logging
1314
import math
1415
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
@@ -27,6 +28,7 @@
2728
S3MultipartUpload,
2829
S3MultipartUploadPart,
2930
S3ObjectVersion,
31+
S3PutObject,
3032
)
3133
from pyathena.filesystem.s3_path import S3Path
3234
from pyathena.util import RetryConfig, override, retry_api_call
@@ -606,6 +608,92 @@ def head_bucket(self, bucket: str, **params) -> S3Bucket:
606608
response = self.call(self._client.head_bucket, Bucket=bucket, **params)
607609
return S3Bucket(name=bucket, bucket_region=response.get("BucketRegion"))
608610

611+
def get_object(
612+
self, path: S3Path, range_: tuple[int, int | None] | None = None, **params
613+
) -> bytes:
614+
"""Read an object, a version of it, or a byte range of it with GetObject.
615+
616+
The body of the response is read whole and then closed, also when
617+
the read fails. A failure to read the body is neither translated nor
618+
retried: the botocore exception, such as ``ReadTimeoutError`` or
619+
``IncompleteReadError``, propagates.
620+
621+
Args:
622+
path: The path of the object, with the version ID to read, if
623+
any.
624+
range_: The ``(start, end)`` byte range to read, with an exclusive
625+
end, or None as the end to read to the end of the object. A
626+
negative start without an end reads the last ``-start``
627+
bytes, or the whole object if it is shorter. None sends no
628+
range of its own, so the whole object is read unless
629+
``params`` or ``request_kwargs`` have ``Range``.
630+
**params: Additional request parameters. The fields that the
631+
other arguments set take precedence over parameters of the
632+
same name.
633+
634+
Returns:
635+
The bytes read.
636+
637+
Raises:
638+
ValueError: If the path has no key, or the range is empty or has
639+
both a negative start and an end, which S3 would ignore and
640+
return the whole object for.
641+
FileNotFoundError: If the object or version does not exist.
642+
OSError: If the range starts at or past the end of the object.
643+
Its ``__cause__`` is the ``ClientError`` with the
644+
``InvalidRange`` code.
645+
"""
646+
if not path.key:
647+
raise ValueError(f"The path has no key: {path.uri}.")
648+
request: dict[str, Any] = {"Bucket": path.bucket, "Key": path.key}
649+
if path.version_id:
650+
request.update({"VersionId": path.version_id})
651+
if range_ is not None:
652+
start, end = range_
653+
if end is None:
654+
request.update({"Range": f"bytes={start}" if start < 0 else f"bytes={start}-"})
655+
elif start < 0 or start >= end:
656+
raise ValueError(f"Invalid range: {range_}.")
657+
else:
658+
request.update({"Range": f"bytes={start}-{end - 1}"})
659+
request = {**params, **request}
660+
_logger.debug(f"Get object: {path.uri} range={request.get('Range')}")
661+
response = self.call(self._client.get_object, **request)
662+
# Read through the StreamingBody, which verifies the length and the
663+
# checksum of the data; entering it would return the raw stream.
664+
with contextlib.closing(response["Body"]) as body:
665+
return cast(bytes, body.read())
666+
667+
def put_object(self, path: S3Path, body: bytes | None = None, **params) -> S3PutObject:
668+
"""Write an object with PutObject.
669+
670+
Args:
671+
path: The path of the object to write, without a version ID.
672+
body: The data to write. None or empty bytes send no body of
673+
their own, so an empty object is written unless ``params`` or
674+
``request_kwargs`` have ``Body``.
675+
**params: Additional request parameters. The fields that the
676+
other arguments set take precedence over parameters of the
677+
same name.
678+
679+
Returns:
680+
The result of the write.
681+
682+
Raises:
683+
ValueError: If the path has no key, or has a version ID, which a
684+
write cannot replace.
685+
"""
686+
if not path.key:
687+
raise ValueError(f"The path has no key: {path.uri}.")
688+
if path.version_id:
689+
raise ValueError(f"Cannot write to a version: {path.uri}.")
690+
request: dict[str, Any] = {"Bucket": path.bucket, "Key": path.key}
691+
if body:
692+
request.update({"Body": body})
693+
_logger.debug(f"Put object: {path.uri}")
694+
response = self.call(self._client.put_object, **{**params, **request})
695+
return S3PutObject(response)
696+
609697
def delete_object(self, path: S3Path, **params) -> None:
610698
"""Delete an object, or a version of it, with DeleteObject.
611699

0 commit comments

Comments
 (0)