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
11 changes: 11 additions & 0 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ the existing object also count toward the limit. A write with `open` that reache
limit raises `ValueError` and aborts its multipart upload. Multipart copies with `cp`
use parts large enough to stay within the limit.

Paths are normalized as in fsspec, which drops a trailing slash, so `info`, `isfile`,

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 01a1012b-b219-74d0-8553-c9578ca54d61. The scope was the docs delta fbd2d0bc3aafa1185a7b3150879fe3ee6111e0a7..da5dedcfabdb057511dae7b935f28e17b6d50d57 (the base 92c9e3e67180eb3d52ef7b85579a24540a30fd64 did not move), with each statement checked against the code paths. This is a static review. The snapshot and the PR worktree were unchanged. Result: CLEAN.

Reviewer output, verbatim:

Surfaces covered, assuming dir/ exists and checking both with and without an object at dir:

Surface Verified behavior
info, isfile Unsuffixed dir/ resolves to dir: file/True when that object exists; directory/False otherwise.
open Reading uses dir or raises FileNotFoundError; writing targets dir.
?versionId=V Preserves the slash key for metadata and reads. Version-qualified writes raise ValueError.
find, ls of the directory Include the slash-key object as a file entry, including when dir also exists.
cat_file, no suffix Whole-object reads, non-empty non-negative ranges—including open-ended ranges—and negative start without end read the slash key directly.
Other unsuffixed ranges Empty/reversed ranges, negative end, and negative start with an explicit end raise FileNotFoundError. The key-mismatch guard also rejects an existing dir object.
Version-qualified cat_file Metadata retains the slash key, allowing negative-offset normalization and empty results. Ranges starting at/past EOF return empty bytes.

CLEAN — no actionable findings in the rewritten paragraph.

Static source review at da5dedcf, including inherited methods in local fsspec 2026.9.0, matching the lockfile. No edits, builds, tests, or network access.

and `open` treat `s3://YOUR_S3_BUCKET/dir/` as `s3://YOUR_S3_BUCKET/dir`: the object
`dir` if it exists, and otherwise the directory `dir`. An object whose key ends in a
slash, such as a folder marker, is therefore not a file for these methods. Opening
`dir/` for reading reads the object `dir` or raises `FileNotFoundError`, and opening it
for writing writes the object `dir`. A path with a `?versionId=` suffix keeps the slash
and refers to the object. `find`, and `ls` of the directory, list the object as a file
entry. `cat_file` uses the key as written. Without a `?versionId=` suffix, it reads

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 01a10125-db3d-7510-b0ae-fc80f31122fc. The scope was the repair delta 91d74a700872a9e1086f0d9a7a982d17015c8387..fbd2d0bc3aafa1185a7b3150879fe3ee6111e0a7 (the base 92c9e3e67180eb3d52ef7b85579a24540a30fd64 did not move), with the affected contracts. This is a static review. The snapshot and the PR worktree were unchanged. Result: FINDINGS (docs only).

Reviewer output, verbatim:

Covered: the exact delta; path parsing, range branches, metadata/cache keys, version handling, prefixes, fsspec callers, async forwarding, open reads/writes, find/ls, and test helpers.

FINDINGS — two documentation issues; no code regression found.

  1. P3 — introduced documentation overstatement: docs/filesystem.md:109 says other ranges raise FileNotFoundError without excluding version-qualified paths. For an existing ten-byte dir/ version, cat_file("s3://bucket/dir/?versionId=VERSION", start=-2, end=-1) successfully reads one byte; (5, 5) returns b"". info() preserves that key, so the new check passes. Qualify this restriction as applying to trailing-slash paths without a version suffix.

  2. P3 — pre-existing claim retained: docs/filesystem.md:103 assumes that a trailing-slash path always becomes a directory and fails when opened for reading. When both dir and dir/ exist, normalization makes info() return the file dir, isfile() return True, and open(..., "rb") read dir. The object lookup and new test’s setup establish this scenario. State that the directory/read-failure behavior assumes no object exists at the normalized key.

The implementation satisfies the requested guards. Ordinary keys, cached/version-aware entries, prefixes, suffix ranges, and non-empty non-negative ranges retain their prior behavior.

Static tracing indicates all six new test cases fail before and pass after for the intended reasons: bucket cases previously reached GetObject; mismatched-key cases previously returned b"" using the unrelated metadata.

No edits, builds, tests, or network access. HEAD remains fbd2d0bc…, with a clean working tree.

Author verification on live S3:

  • 1: cat_file("…/only/?versionId=null", -2, -1) returned b"e", and (5, 5) returned b"". Confirmed.
  • 2: with both and both/, info("…/both/") returned a file of size 10, isfile returned True, and open().read() returned b"0123456789". Confirmed.

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 da5dedc (docs only):

  • info, isfile and open treat dir/ as "the object dir if it exists, and otherwise the directory dir". Opening dir/ for reading reads dir or raises FileNotFoundError.
  • The cat_file range restriction now applies "without a ?versionId= suffix".
  • just docs lint: passed. Every statement in the paragraph matches the live results recorded in this thread and the earlier ones.
  • Self-review of the repair: round one found no behavior changes (docs only). In round two, each sentence was checked against a live result: normalization and existing dir (both/), the directory and read failure (only/), writing w/ → w, ?versionId=null access, find/ls listing, and cat_file ranges with and without a version suffix.

such an object without a range, with a non-empty range of non-negative offsets, or with
a negative `start` and no `end`, and raises `FileNotFoundError` for other ranges.

## Error translation

S3 error responses are translated into standard Python exceptions, so filesystem
Expand Down
76 changes: 45 additions & 31 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1497,9 +1497,11 @@ def cat_file(
``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.
Non-negative offsets are sent to S3 as they are, and so is a negative
``start`` without an ``end``, as a suffix range of the last bytes.
Other negative offsets are 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.
Expand All @@ -1515,36 +1517,44 @@ def cat_file(
The bytes read from the object.

Raises:
FileNotFoundError: If the key does not exist.
FileNotFoundError: If the path has no key or the key does not
exist.
"""
bucket, key, path_version_id = self.parse_path(path)
if not key:
raise FileNotFoundError(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)
):
# 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:
# 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:
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)
if start is not None and start < 0 and end is None:

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 (claims, callers, operations). Base 92c9e3e67180eb3d52ef7b85579a24540a30fd64, head 91d74a700872a9e1086f0d9a7a982d17015c8387. Result: CLEAN.

Claims checked:

  • "S3 returns the last N bytes, or the whole object when it is shorter": live raw GetObject returned bytes=-2 → 206 b"89", bytes=-10 and bytes=-100 → the whole object, bytes=-5 on a 0-byte object → 200 b"", and a missing key → NoSuchKey 404.
  • "s3fs sends the same range": s3fs 2026.9.0 _process_limits sets start="" and end=-start for a negative start with no end (bytes=-N). Live, s3fs read b"bc" from slash/.
  • The docs paragraph: on live S3, info reports a directory, isfile is False, and open raises FileNotFoundError. find and ls of the directory list the key as a file. cat_file reads it without a range, with [0:1] and with start=-2, and raises for (-2, 3), (0, -1) and (5, 5).
  • The release-note claim that this used to return the whole object (and, with Clamp byte ranges to the object in cat_file() and S3File reads #987, FileNotFoundError): measured in the Objects whose keys end in '/' are listed as files but cannot be opened or read with negative offsets #994 reproduction on aa0fc914 and 02f8ebdd.

Callers and operations: a negative start without an end now sends one GetObject instead of info() (HeadObject, or ListObjectsV2 for a missing key, unless cached) plus GetObject. Requests decrease, retries are unchanged, and a stale cached size no longer affects the result.

# S3 returns the last bytes, or the whole object when it is
# shorter, without the size of the object.
ranges = (start, None)
else:
if (start is not None and start < 0) or (
end is not None and (end < 0 or (start or 0) >= end)
):
# 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 or info.key != key:

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 review (relayed): Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a10116-8794-7263-8855-6c44ece03ab5. It ran on a detached snapshot of head 91d74a700872a9e1086f0d9a7a982d17015c8387 against base 92c9e3e67180eb3d52ef7b85579a24540a30fd64. 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. Result: FINDINGS.

Reviewer output, verbatim:

Covered the exact range, cat_file, inherited cat_ranges, async forwarding, _get_object, buffered reads, multipart-copy callers, version IDs, prefixes, missing/empty objects, slash-ending keys, docstrings, filesystem documentation, and relevant tests.

FINDINGS

  1. P2 — Regression: bucket-only suffix reads leak an SDK exception.
    pyathena/filesystem/s3.py:1527
    For an accessible bucket, fs.cat_file("s3://bucket/", start=-1) previously obtained directory metadata and raised FileNotFoundError. The new branch bypasses that check and sends Key=None to boto3, producing ParamValidationError. Neither exception handler translates it. This also affects async and cat_ranges callers. Reject bucket-only paths before issuing GetObject; the changed directory tests omit this case.

  2. P2 — Introduced documentation error: trailing-slash behavior is overstated.
    docs/filesystem.md:101
    The paths are not universally equivalent: with only dir/ containing b"abc", cat_file("s3://bucket/dir/", start=-2) returns b"bc", while the slashless call raises FileNotFoundError. Also, info/isfile/read-mode open can access the actual slash-ending object through dir/?versionId=v, because the slash before the query survives normalization. Write-mode open(".../dir/", "wb") can create or overwrite dir instead of raising. Qualify the paragraph by method, mode, and version syntax.

  3. P2 — Pre-existing: bounded negative ranges can use another key’s size.
    pyathena/filesystem/s3.py:1538
    Suppose dir contains ten bytes and dir/ contains b"abc". Calling cat_file("s3://bucket/dir/", start=-2, end=-1) obtains dir’s size through slash-stripping info(), then requests bytes=8-8 from dir/. Its InvalidRange becomes b"", whereas the requested slice is b"b". This logic exists at the merge-base. Metadata lookup must identify the same literal key as GetObject.

The suffix header itself matches RFC 9110 and the inspected fsspec/s3fs implementation. Version precedence and buffered-read/multipart-copy callers show no further regression. Ordinary marker entries are listed as files by ls/find, as documented.

The new tests would catch the old stale-size and slash-marker suffix failures. Mocking info() means they do not establish the documentation’s classification claims; empty-object suffix handling also lacks direct coverage.

I read fsspec 2026.9.0 in the supplied .venv and s3fs 2026.9.0 in the local uv cache. Those comparisons used source, not recollection. Static review only: no edits, builds, tests, GitHub, or network access.

Author verification on live S3 (master 92c9e3e6 vs 91d74a70):

  • 1: cat_file("s3://bucket/", start=-1) raised FileNotFoundError on master and ParamValidationError on the branch. Confirmed regression. Without a range, both raise ParamValidationError, which is pre-existing.
  • 2: cat_file("…/only/", start=-2) returned b"bc", while "…/only" raised FileNotFoundError. open("…/w/", "wb") created the key w. "…/only/?versionId=null" gave info = file of size 3, isfile = True and open = b"abc". Confirmed docs overstatement.
  • 3: with both (10 bytes) and both/ (b"abc"), cat_file("…/both/", -2, -1) returned b"" on master and on the branch. Confirmed, pre-existing.

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 fbd2d0b:

  • 1: cat_file() raises FileNotFoundError for a path without a key before any request, with or without a range. Release note: an unranged cat_file("s3://bucket") used to raise ParamValidationError.
  • 3: when info() is used (a negative end, a negative start with an end, or an empty range), cat_file() raises FileNotFoundError unless the returned entry is a file with the same key. info() of dir/ describes dir, so its size is no longer applied to dir/. A ?versionId= path keeps its slash and matches, as cat_file("…/only/?versionId=null", 0, -1) returning b"ab" shows. The redundant cast(str, key) was dropped.
  • 2: the docs paragraph now says that info, isfile and open normalize the trailing slash. Reading raises FileNotFoundError, and writing dir/ writes dir. A ?versionId= suffix keeps the slash. find, and ls of the directory, list the object as a file. cat_file uses the key as written, reads it without a range, with non-empty non-negative ranges or with a suffix range, and raises for other ranges. Every statement was checked on live S3.

Validation on fbd2d0b:

  • just lint and just docs lint: passed.
  • uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/: 378 passed.
  • New test_cat_file_bucket (3 cases) and test_cat_file_range_key_ending_in_slash (3 cases) fail on 91d74a70.
  • Live S3: the bucket path raises FileNotFoundError with and without a range, and both/ raises FileNotFoundError for (-2, -1) and (5, 5). The only//slash/ results match the docs.

Self-review of this repair:

  • Round one (behavior): the key check runs only on the info() path, so suffix ranges and non-negative ranges are unchanged. A file entry from a listing cache carries the listed key, which equals the parsed key for normal paths. The directory check is kept for plain prefixes, whose key equals the directory's key.
  • Round two (claims): the commit message, the cat_file() Raises: text ("If the path has no key or the key does not exist") and the docs statements were checked against the live results above. The PR body is updated with the new release note.

# There is no object to read, as GetObject reports for
# the other ranges, or info() describes the key without
# the trailing slash of this one.
raise FileNotFoundError(path)
start, end, _ = slice(start, end).indices(info.get("size", 0))
if start is not None or end is not None:
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),
key=key,
ranges=ranges,
version_id=version_id,
**kwargs,
Expand Down Expand Up @@ -2154,14 +2164,15 @@ def _get_object(
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.
end or ``None`` to read to the end of the object (the last
``-start`` bytes for a negative start), 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.
Tuple of the start of the range as given (0 for the whole
object) and the bytes read.

Raises:
ValueError: If the range is empty. S3 ignores a range whose last
Expand Down Expand Up @@ -2775,13 +2786,16 @@ def _format_ranges(ranges: tuple[int, int | None]) -> str:

Args:
ranges: The ``(start, end)`` byte range, with an exclusive end or
``None`` for the end of the object.
``None`` for the end of the object. A negative start with no
end selects the last ``-start`` bytes.

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

@staticmethod
def _get_ranges(
Expand Down
53 changes: 46 additions & 7 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,9 +743,10 @@ def test_cat_file_version_id(self, path, expected):

def _make_object_fs(self, data):
# A filesystem holding one object at s3://bucket/key whose client
# answers GetObject like S3: InvalidRange when the range starts at
# or past the end of the object, and a failure on a range that S3
# would answer with the whole object (last byte before the first).
# answers GetObject like S3: the last bytes for a suffix range,
# InvalidRange when the range starts at or past the end of the
# object, and a failure on a range that S3 would answer with the
# whole object (last byte before the first).
# info() reports the given size, and the requested ranges are
# recorded.
fs = self._make_fs()
Expand All @@ -760,6 +761,8 @@ def get_object(**request):
ranges.append(range_)
if range_ is None:
return {"Body": io.BytesIO(data)}
if suffix := re.fullmatch(r"bytes=-(\d+)", range_):
return {"Body": io.BytesIO(data[-int(suffix[1]) :])}
match = re.fullmatch(r"bytes=(\d+)-(\d*)", range_)
assert match, range_
first = int(match[1])
Expand All @@ -785,6 +788,8 @@ def get_object(**request):
(5, None),
(1, -1),
(-5, None),
(-10, None),
(-100, None),
(5, 100),
(-100, 5),
# Empty ranges.
Expand All @@ -804,10 +809,14 @@ def test_cat_file_range(self, start, end):

# The range selects bytes like a slice.
assert fs.cat_file("s3://bucket/key", start=start, end=end) == data[start:end]
suffix = (start or 0) < 0 and end is None
negative = (start or 0) < 0 or (end or 0) < 0
empty = end is not None and 0 <= end <= (start or 0)
# Only a negative offset or an empty range looks up the object.
assert fs.info.called == (negative or empty)
# Only a negative offset with an end, a negative end, or an empty
# range looks up the object.
assert fs.info.called == ((negative and not suffix) or empty)
if suffix:
assert ranges == [f"bytes={start}"]
if empty:
assert ranges == []

Expand All @@ -818,7 +827,17 @@ def test_cat_file_range_stale_size(self):

assert fs.cat_file("s3://bucket/key", start=10, end=20) == b"abcdefghij"
assert fs.cat_file("s3://bucket/key", start=15) == b"fghij"
assert ranges == ["bytes=10-19", "bytes=15-"]
assert fs.cat_file("s3://bucket/key", start=-5) == b"fghij"
assert ranges == ["bytes=10-19", "bytes=15-", "bytes=-5"]

def test_cat_file_suffix_range_key_ending_in_slash(self):
fs, _ = self._make_object_fs(b"abc")
# info() reports a key ending in "/" as a directory.
fs.info.return_value = S3FileSystem._directory_object("bucket", "dir")

assert fs.cat_file("s3://bucket/dir/", start=-2) == b"bc"
fs._client.get_object.assert_called_once_with(Bucket="bucket", Key="dir/", Range="bytes=-2")
fs.info.assert_not_called()

def test_cat_file_range_errors(self):
fs = self._make_fs()
Expand Down Expand Up @@ -851,7 +870,7 @@ def test_cat_file_empty_range_missing(self, start, end):
fs.cat_file("s3://bucket/missing", start=start, end=end)
fs._call.assert_not_called()

@pytest.mark.parametrize(("start", "end"), [(-5, None), (0, -1), (5, 5)])
@pytest.mark.parametrize(("start", "end"), [(-5, 3), (0, -1), (5, 5)])
def test_cat_file_range_directory(self, start, end):
fs = self._make_fs()
fs.info = mock.MagicMock(return_value=S3FileSystem._directory_object("bucket", "dir"))
Expand All @@ -861,6 +880,25 @@ def test_cat_file_range_directory(self, start, end):
fs.cat_file("s3://bucket/dir", start=start, end=end)
fs._call.assert_not_called()

@pytest.mark.parametrize(("start", "end"), [(None, None), (-1, None), (0, 5)])
def test_cat_file_bucket(self, start, end):
fs = self._make_fs()

with pytest.raises(FileNotFoundError):
fs.cat_file("s3://bucket/", start=start, end=end)
fs._call.assert_not_called()

@pytest.mark.parametrize(("start", "end"), [(-2, -1), (0, -1), (5, 5)])
def test_cat_file_range_key_ending_in_slash(self, start, end):
fs, ranges = self._make_object_fs(b"0123456789")
# info() of "dir/" describes the object "dir" when both exist.
fs.info.return_value = self._file_object("dir")

# The size of "dir" is not used for the range of "dir/".
with pytest.raises(FileNotFoundError):
fs.cat_file("s3://bucket/dir/", start=start, end=end)
assert ranges == []

def test_get_file_directory(self, tmp_path):
fs = self._make_fs()
fs.default_cache_type = "bytes"
Expand Down Expand Up @@ -2837,6 +2875,7 @@ def test_get_ranges(self, start, end, max_workers, worker_block_size, ranges):
def test_format_ranges(self):
assert S3File._format_ranges((0, 100)) == "bytes=0-99"
assert S3File._format_ranges((100, None)) == "bytes=100-"
assert S3File._format_ranges((-8, None)) == "bytes=-8"

@pytest.mark.parametrize("autocommit", [True, False])
def test_upload_chunk_small_file(self, autocommit):
Expand Down
Loading