Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 105 additions & 30 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,13 @@ def cat_file(
) -> bytes:
"""Read the contents of an S3 object with GetObject.

``start`` and ``end`` select bytes like a slice of the object: an
empty range, or one that starts at or past the end of the object,
returns ``b""``, and an end past the object reads up to its end.
Non-negative offsets are sent to S3 as they are; a negative offset is
resolved against the size from :meth:`info`, which also checks that
the object exists for an empty range.

Args:
path: S3 path (s3://bucket/key) of the object.
start: Byte offset to start reading at. A negative value counts
Expand All @@ -1470,38 +1477,51 @@ def cat_file(

Returns:
The bytes read from the object.

Raises:
FileNotFoundError: If the key does not exist.
"""
bucket, key, path_version_id = self.parse_path(path)
version_id = kwargs.pop("version_id", None)
if path_version_id:
version_id = path_version_id
if (start is not None and start < 0) or (
end is not None and (end < 0 or (start or 0) >= end)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent follow-up review (relayed): Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a100df-9260-77a2-907b-ca289cf98428. Full review of the expanded scope on a detached snapshot of head 02f8ebdde0c337ac59d6bc71f6d479eea1da0c6c against base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe. The prompt contained the literal diff and the repository conventions, without PR text, commit messages or prior findings. This is a static review. Afterwards, the snapshot and the PR worktree were unchanged at that head. Result: FINDINGS.

Reviewer output, verbatim:

Covered the supplied diff, synchronous and async cat_file/cat_ranges/get_file, _get_object callers, version handling, prefixes, buckets, empty objects, error translation/retries, docstrings, and tests. Traced all ten built-in fsspec caches: none, bytes, readahead, adaptive, first, mmap, blockcache, background, all, and parts.

Used installed fsspec 2026.9.0 source from the main checkout’s .venv. The s3fs comparison uses API knowledge; s3fs was not installed.

FINDINGS

  1. P2 — Regression: empty ranges suppress missing-object errors.
    pyathena/filesystem/s3.py:1491

    With an uncached, nonexistent key, fs.cat_file("s3://bucket/missing", start=0, end=0) now returns b"" without checking existence. Previously, info() raised FileNotFoundError. A prefix without an object or a nonexistent version similarly returns success. This also propagates through cat_ranges and the async wrappers, contradicting the new Raises documentation and fsspec’s open-before-read behavior.

    Validate the target before this early return. The new tests miss this combination: missing-key coverage uses a nonempty range, and directory coverage uses negative offsets.

  2. P3 — New comment overstates protection against a pre-existing download failure.
    pyathena/filesystem/s3.py:1577

    Cache metadata for a nonempty object, delete it externally, then call get_file() with the default cache. open() succeeds using cached metadata; the destination is created or truncated; the first read raises FileNotFoundError, leaving an empty local file. Thus the new comment’s unconditional “no local file is left behind” claim is false.

    The underlying behavior predates this change. Narrow the comment to failures detected during open(). The directory test does not exercise a failure deferred until the first read.

The fake deliberately rejects malformed ranges instead of returning S3’s whole-object response. Static tracing shows this still detects the targeted EOF defects; it does not falsely pass them. However, it does not model missing keys/versions or conditional-request failures.

No files changed; no builds, tests, GitHub access, or network access performed.

Author verification:

  • 1: Confirmed regression. On 02f8ebdd, a missing key, a prefix, and a missing version with (0, 0), (5, 3) or (None, 0) returned b"" without any lookup. On master, info() raised FileNotFoundError.
  • 2: Confirmed. The comment overstated the effect. The empty local file left when an object is deleted after its metadata was cached is pre-existing: open() succeeds from the cache and the first read fails.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repaired in 97ce192:

  • 1: an empty range (end is not None and (start or 0) >= end) now looks up the object with info(), as negative offsets do. A missing object or version raises FileNotFoundError from info(), and a prefix raises it through the directory check. Neither sends a GetObject request.
  • 2: the get_file() comment now says that no local file is created "when open() finds no object at the path". The failure deferred to the first read stays as before; it is pre-existing and not changed here.

Validation on 97ce192:

  • just lint: passed.
  • uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/: 306 passed.
  • New test_cat_file_empty_range_missing ((0, 0), (5, 3), (None, 0)) and test_cat_file_range_directory[5-5] fail on 02f8ebdd.
  • test_cat_file_range now asserts that info() is called exactly for negative offsets and empty ranges.
  • Live S3: a missing key and a prefix raise FileNotFoundError for all three empty ranges, and an existing object's (5, 5) returns b"".

Self-review of this repair:

  • Round one (behavior): the repair adds an info() call only for empty ranges. Master called info() for every ranged read, so this restores master's existence check. Non-empty non-negative ranges still skip info().
  • Round two (claims): the cat_file() docstring now says that info() also checks existence for an empty range. The PR body is updated. The existence check may use cached metadata, as master's info() did.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent narrow follow-up review (relayed): Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a100ea-f792-7363-93c9-920de153308c. The scope was the repair delta 02f8ebdde0c337ac59d6bc71f6d479eea1da0c6c..97ce19220f328e9eced2384b1c674d0de571c5b6 (the base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe did not move), with the affected contracts listed and the cumulative diff readable. This is a static review. Afterwards, the snapshot and the PR worktree were unchanged at 97ce1922. Result: CLEAN.

Reviewer output, verbatim:

CLEAN — no actionable regressions found in 02f8ebdd..97ce1922.

Surfaces covered: cat_file(), version resolution, info() and its cache/HEAD/prefix paths, S3 error translation, _get_object(), fsspec 2026.9.0 callers (cat, cat_ranges, read_bytes), async delegation, and get_file() → open() → S3File.

  • pyathena/filesystem/s3.py:1481: Empty non-negative ranges now perform the version-aware metadata lookup. Missing keys/versions propagate FileNotFoundError; directory results are explicitly rejected. Existing objects return b"" before any GetObject. Non-empty non-negative ranges still skip info(), and negative-offset behavior is unchanged.
  • pyathena/filesystem/s3.py:1583: The revised comment accurately describes evaluation order: an exception from remote open() prevents opening the local destination.

Static comparison of the changed tests predicts seven distinguishing cases:

Tests Against 02f8ebdd Against 97ce1922
Range tests:645: (0,0), (5,5), (7,3) Fail the expected info() call assertion Pass; return empty without GET
Missing-object tests:688: all three cases Fail because no exception is raised Pass by propagating the lookup failure
Directory test:698: (5,5) Fails because no exception is raised Passes through directory rejection

The other parameterized cases retain their previous behavior. Missing-version handling was traced through source; the new missing-object tests mock info().

Pre-existing limitation: lookup freshness still follows info()’s cache semantics. This patch does not change that behavior.

Static review only; no tests, builds, writes, or network access. HEAD remained 97ce1922, with a clean worktree.

):
# A negative offset needs the size of the object, and an empty
# range sends no GetObject request that would report a missing
# object.
info = self.info(path, version_id=version_id)
if info.get("type") == S3ObjectType.S3_OBJECT_TYPE_DIRECTORY:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (implementation behavior). Base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe, head 0b0e15053a153806f4f4525deee9956c36e55aeb. Result: FINDINGS (1).

Covered: cat_file() range resolution (slice semantics; negative, None, reversed and past-the-end offsets; empty objects; a non-integer offset now raises TypeError from slice.indices instead of sending a malformed Range), delegation of cat_ranges() and AioS3FileSystem._cat_file() to cat_file(), S3File._fetch_range() clamping before _get_ranges() splits for max_workers > 1, the fsspec 2026.9.0 FirstChunkCache, MMapCache and BaseCache fetch paths, the callers of _get_object() (only cat_file() and _fetch_range(), both guarded), and whether the offline stub could pass by omitting S3 behavior (it rejects any range that S3 would answer with other than the requested bytes, and the 13 defect cases fail on master).

Finding: info() reports a prefix as a directory with size 0. With the range clamped, cat_file("s3://bucket/prefix", start=0, end=5) resolved to an empty range and returned b"". On master it raises FileNotFoundError from GetObject, with or without a range. Checked on live S3 with an object under prefix/.

Out of scope, pre-existing: fs.open(prefix, "rb").read() returns b"" on master and on this branch, because info() reports size 0. This PR does not change it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repaired in 2452e4f: cat_file() raises FileNotFoundError for a directory before the range is resolved, with no request. The docstring gains a Raises: section.

  • New offline test test_cat_file_range_directory covers (0, 5) and (5, None).
  • The live S3 check returns FileNotFoundError for {}, start=0, end=5 and start=5 on a prefix.
  • just lint passed.
  • uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/: 296 passed.

# There is no object to read, as GetObject reports for the
# other ranges.
raise FileNotFoundError(path)
start, end, _ = slice(start, end).indices(info.get("size", 0))

ranges: tuple[int, int | None] | None = None
if start is not None or end is not None:
size = self.info(path, version_id=version_id).get("size", 0)
if start is None:
range_start = 0
elif start < 0:
range_start = size + start
else:
range_start = start

if end is None:
range_end = size
elif end < 0:
range_end = size + end
else:
range_end = end

ranges = (range_start, range_end)
else:
ranges = None

return self._get_object(
bucket=bucket,
key=cast(str, key),
ranges=ranges,
version_id=version_id,
**kwargs,
)[1]
start = start or 0
if end is not None and start >= end:
# S3 would return the whole object for an empty range.
return b""
ranges = (start, end)
try:
return self._get_object(
bucket=bucket,
key=cast(str, key),
ranges=ranges,
version_id=version_id,
**kwargs,
)[1]
except OSError as e:
if (
ranges
and isinstance(e.__cause__, botocore.exceptions.ClientError)
and S3ClientError(e.__cause__).code == "InvalidRange"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one, after the independent-review repair (implementation behavior). This is a full pass, because the repair expanded the contract. Base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe, head 20d09135c4edef805e879eb718ff33afe64e48bb. Result: FINDINGS (1 simplification, repaired in 02f8ebdde0c337ac59d6bc71f6d479eea1da0c6c).

Covered:

  • cat_file(): non-negative, negative and None offsets; open-ended ranges; a 0-byte object; prefixes; keys ending in /; version IDs (non-negative reads never used info() for the version sent to GetObject).
  • InvalidRange detection: _call() raises S3ClientError(e).os_error from e, so __cause__ is the ClientError. RetryConfig retries only THROTTLING_ERROR_CODES, so the 416 is not retried. Only ranged requests map it to b"".
  • _fetch_range(): it clamps to the size pinned at open, and reads are sent with IfMatch on the ETag, so the object cannot change underneath.
  • S3File.__init__: the directory check sits before version pinning, and a bucket path still raises ValueError first. AioS3File inherits it.
  • get_file(): the open order changed, and the callback and size use are unchanged.
  • The test fake is at the client level and keeps the real _call(), so the error translation is exercised.

Finding: the condition 0 <= start >= end >= 0 in test_cat_file_range was hard to read. It is now 0 <= end <= start with no change in meaning.

Out of scope: recursive get() of a prefix fails on master and on this branch (#974); only the error type differs.

):
# The range starts at or past the end of the object.
return b""
raise

def put_file(self, lpath: str, rpath: str, callback=_DEFAULT_CALLBACK, **kwargs):
"""Upload a local file to S3.
Expand Down Expand Up @@ -1567,7 +1587,9 @@ def get_file(self, rpath: str, lpath: str, callback=_DEFAULT_CALLBACK, outfile=N
if os.path.isdir(lpath):
return

with open(lpath, "wb") as local, self.open(rpath, "rb", **kwargs) as remote:
# The remote file is opened first so that no local file is created
# when open() finds no object at the path.
with self.open(rpath, "rb", **kwargs) as remote, open(lpath, "wb") as local:
callback.set_size(remote.size)
while data := remote.read(remote.blocksize):
local.write(data)
Expand Down Expand Up @@ -2079,12 +2101,33 @@ def _get_object(
self,
bucket: str,
key: str,
ranges: tuple[int, int] | None = None,
ranges: tuple[int, int | None] | None = None,
version_id: str | None = None,
**kwargs,
) -> tuple[int, bytes]:
"""Read an object or a byte range of it with GetObject.

Args:
bucket: The bucket name.
key: The object key.
ranges: The ``(start, end)`` byte range to read, with an exclusive
end or ``None`` to read to the end of the object, or ``None``
to read the whole object.
version_id: The version ID to read, or ``None`` for the latest.
**kwargs: Additional parameters passed to the GetObject API.

Returns:
Tuple of the start of the range (0 for the whole object) and the
bytes read.

Raises:
ValueError: If the range is empty. S3 ignores a range whose last
byte precedes its first byte and returns the whole object.
"""
request = {"Bucket": bucket, "Key": key}
if ranges:
if ranges[1] is not None and ranges[0] >= ranges[1]:
raise ValueError(f"Invalid empty range: {ranges}.")
range_ = S3File._format_ranges(ranges)
request.update({"Range": range_})
else:
Expand Down Expand Up @@ -2270,6 +2313,8 @@ def __init__(
**kwargs: Accepted for compatibility; not used.

Raises:
FileNotFoundError: If no object exists at the path when reading,
including when the path is a prefix.
ValueError: If the path has no key, the version IDs do not match,
a version is given for writing, or the block size is not
between ``MULTIPART_UPLOAD_MIN_PART_SIZE`` and
Expand Down Expand Up @@ -2321,6 +2366,9 @@ def __init__(
# Looked up before the base class initializer, which would
# otherwise take the size from the latest version of the object.
info = fs.info(path, version_id=self.version_id)
if info.get("type") == S3ObjectType.S3_OBJECT_TYPE_DIRECTORY:
# A prefix has no object to read.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two, after the independent-review repair (claims, callers, operations). This is a full pass. Base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe, head 02f8ebdde0c337ac59d6bc71f6d479eea1da0c6c. Result: FINDINGS (PR-body wording, corrected).

Claims checked:

  • "S3 clamps an end past the object": live bytes=5-100 returned 206 with b"56789".
  • "InvalidRange (HTTP 416)": live bytes=12-19 and bytes=10-19 returned InvalidRange with status 416.
  • "A 0-byte object returns b''": live, (0, None), (0, 5) and (-5, None), and the bytes, all, first and mmap caches.
  • Stale size, keys ending in /, open(prefix) and get_file(prefix): live runs as stated in the PR body.
  • The docstrings of cat_file(), _get_object(), _format_ranges(), _fetch_range() and S3File.__init__ (Raises) match the code.

Corrections to the PR body:

  • The open(prefix) release note said that "all"/"first" read b"" before. On master, they raised FileNotFoundError from GetObject when the data was fetched. The note now says so.
  • The info() cost note now includes ListObjectsV2 for a missing key.

Callers and operations:

  • Non-negative ranged reads send one GetObject and no info(), which is fewer requests than master. cat_ranges over many ranges, as parquet readers do, benefits.
  • open() of a prefix now raises; on master it silently read b"". This is a release note.
  • get_file() no longer leaves an empty local file when the remote open fails, including for a missing key, which also left one on master.

Imprecision not rewritten: commit 20d0913 says that the synthesized size "made reads of keys ending in '/' empty". That describes 0b0e150; at 2452e4f, the same read raised FileNotFoundError. The branch is not rewritten for a commit-message nuance.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review after rebase (rounds one and two, narrow). Old aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe..97ce19220f328e9eced2384b1c674d0de571c5b6, new 6f258501a56124f6e29c6d530ce429b702ed2ab3..fd97af364bdbce5ece2c015124da686acaa0f582. Both old objects were verified with git cat-file -e. Result: CLEAN.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent rebase follow-up review (relayed): Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a100f8-c56d-7a01-bdd5-9741469b28c2. The scope was the git range-diff from aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe..97ce19220f328e9eced2384b1c674d0de571c5b6 to 6f258501a56124f6e29c6d530ce429b702ed2ab3..fd97af364bdbce5ece2c015124da686acaa0f582, plus the upstream filesystem changes between the two bases. This is a static review. Afterwards, the snapshot and the PR worktree were unchanged at fd97af36. Result: CLEAN.

Reviewer output, verbatim:

CLEAN — no actionable findings in the rebase or the inspected upstream interactions.

Surfaces covered:

  • Conflict resolutions: The local range-diff matches the supplied output. Differences are confined to import and docstring context; the reviewed implementation and tests are preserved.
  • cat_file() and range helpers: Negative offsets, empty/reversed ranges, EOF handling, version selection, missing objects/prefixes, and selective InvalidRange handling remain consistent. Multipart-copy range formatting is unchanged.
  • S3File.__init__: Prefix rejection remains before fsspec initialization. Write-size validation leaves small read blocks valid. Append preloading, version/ETag handling, and executor initialization remain compatible with the series.
  • _fetch_range() and executors: Clamping precedes request splitting. Checked fsspec 2026.9.0’s none, first, mmap, bytes, and eager all cache paths. The executor changes preserve future completion and ordered result assembly.
  • Multipart cleanup: Cancellation and waiting before abort introduce no identified interaction with the read contracts.
  • get_file() and tests: Remote opening still precedes local creation/truncation. The new range, prefix, version, and cache tests remain consistent with the rebased implementation and fixtures.

Regressions: None found.
Additional pre-existing issues: None identified within the inspected scope.

Static review only; no edits, builds, tests, GitHub, or network access. HEAD remains fd97af364bdbce5ece2c015124da686acaa0f582, with a clean worktree.

raise FileNotFoundError(path)
if fs.version_aware and not self.version_id:
# Pin the version observed at open time so that reads are
# consistent even if the object is overwritten. info() heads
Expand Down Expand Up @@ -2602,6 +2650,23 @@ def setxattr(self, copy_kwargs: dict[str, Any] | None = None, **kwargs) -> None:
self.fs.setxattr(self.path, copy_kwargs=copy_kwargs, **kwargs)

def _fetch_range(self, start: int, end: int) -> bytes:
"""Read a byte range of the object for the fsspec cache.

The range is clamped to the size of the object, since fsspec caches
may request a range that is empty or reaches past the end of the
object. S3 would answer the former with the whole object and a range
starting past the end with an ``InvalidRange`` error.

Args:
start: The offset of the first byte to read.
end: The offset to stop reading at (exclusive).

Returns:
The bytes read, empty if the clamped range is empty.
"""
end = min(end, self.size)
if start >= end:
return b""
ranges = self._get_ranges(
start, end, max_workers=self.max_workers, worker_block_size=self.blocksize
)
Expand Down Expand Up @@ -2629,8 +2694,18 @@ def _fetch_range(self, start: int, end: int) -> bytes:
return object_

@staticmethod
def _format_ranges(ranges: tuple[int, int]):
return f"bytes={ranges[0]}-{ranges[1] - 1}"
def _format_ranges(ranges: tuple[int, int | None]) -> str:
"""Format a byte range as the value of an HTTP ``Range`` header.

Args:
ranges: The ``(start, end)`` byte range, with an exclusive end or
``None`` for the end of the object.

Returns:
The range, such as ``bytes=0-99`` or ``bytes=100-``.
"""
start, end = ranges
return f"bytes={start}-" if end is None else f"bytes={start}-{end - 1}"

@staticmethod
def _get_ranges(
Expand Down
Loading
Loading