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
25 changes: 18 additions & 7 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,11 @@ def ls(
Returns:
List of S3Object instances (if detail=True) or paths as strings (if detail=False).

Raises:
FileNotFoundError: If the bucket does not exist, or if a key path

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, static). Reviewer: Codex CLI 0.164.0, model gpt-6-astra, codex exec -s read-only, session 01a178cf-4e17-7cf3-b831-2ea730a8f4a3. Base 1a6a0d6, head aaff8b1, run in a detached snapshot of that head; the prompt held the diff, source, and repository conventions, without the PR text, commits, or prior findings. No edits, tests, builds, or network access. The snapshot and PR worktree were unchanged afterwards.

Covered: the exact diff; bucket, object, prefix and version-qualified listings; versions, refresh, detail; cache lookup and eviction; async delegation, info, find/_find, internal callers; fsspec's walk, _walk, listdir, tree and the conformance fixtures; added and rewritten tests and the removed xfails ("The added tests protect distinct contracts; rewritten tests retain eviction coverage; conformance methods remain inherited with their fixtures and cleanup").

Result: FINDINGS.

Introduced:

  • P3 pyathena/filesystem/s3.py:551: the new Raises text conflates an S3 key prefix with a directory. With only bucket/p/test_0, ls("bucket/p/test_") raises although p/test_ is a key prefix of an existing key; the test comment at tests/pyathena/filesystem/test_s3.py:4885 says the same.

Pre-existing, not introduced by the diff (both reproduced by the author with a stubbed client):

  • P2 pyathena/filesystem/s3.py:508: a missing-bucket listing error leaves a cached positive listing. Cache a populated bucket, delete it, ls(bucket, refresh=True) raises FileNotFoundError, and the next ls(bucket) returns the deleted entries.
  • P2 pyathena/filesystem/s3.py:572: with bucket/key/child present and no version v1 of bucket/key, ls("bucket/key?versionId=v1", refresh=True) returns the child listing without checking the version; _head_object() sees the version only when the listing is empty.

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.

Repair of the P3 finding in a8a020a: the Raises text now says a key path that "is neither an object nor a directory, a path that some key starts with followed by /", and the test_ls_dirs comment says "directory". The PR body uses the same wording.

Self-review of the repair. Round one: docstring and comment only, no executable code or assertion changed; just lint passed. Round two: the wording matches _ls_dirs() (lists key/), the exact-object HeadObject fallback, _ls_object_versions() (same / boundary, then exact-key versions), and a dir/ marker (a key that starts with dir/). CLEAN.

Independent follow-up (relayed, static): Codex CLI 0.164.0, gpt-6-astra, codex exec -s read-only, session 01a178d8-a1c9-7f70-a80b-e1e17ad932ac, range aaff8b1..a8a020a in a detached snapshot of a8a020a. Result: CLEAN — "Ordinary listing searches key/, then falls back to the exact object. Thus p/test_0 alone does not make p/test_ listable; the revised wording and fixture comment correctly describe this"; versions=True, a dir/-marker-only directory, empty and missing buckets were also checked.

The two pre-existing P2 findings are not changed by this PR; they are left for a separate issue.

is neither an object nor a directory, a path that some key
starts with followed by ``/``.

Example:
>>> fs = S3FileSystem()
>>> fs.ls("s3://my-bucket") # List objects in bucket
Expand All @@ -560,14 +565,20 @@ def ls(
path = self._strip_protocol(path).rstrip("/")
if path in ["", "/"]:
files = self._ls_buckets(refresh)
elif versions:
files = self._ls_object_versions(path)
else:
files = self._ls_dirs(path, refresh=refresh)
if not files and "/" in path:
file = self._head_object(path, refresh=refresh)
if file:
files = [file]
key = S3Path.parse(path).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.

Self-review round one (behavior and implementation), finding 1 of 2.
Base 1a6a0d6, reviewed head 3fe812a. Covered: ls() for bucket/key/version paths, _ls_dirs/_head_object/_ls_object_versions cache eviction, AioS3FileSystem._ls (delegates to the sync ls()), fsspec callers of ls (walk, _walk, listdir, tree; find/glob/info/exists do not call it), the updated and new tests, and the removed xfail marks. Passes: my own read, /code-review (high), /simplify (reuse, simplification, efficiency, altitude).

FINDING: the new raise decided "key path" with "/" in path. ls("s3://bucket/?versionId=v1") keeps bucket/?versionId=v1 after rstrip("/"), so an existing empty bucket raised FileNotFoundError instead of returning [] (and the pre-existing fallback sent a HeadObject without a key). Repaired in 0de3190: the key is taken from S3Path.parse(path).key, as the other lookups do; test_ls_empty_bucket gained the version-query case, which fails with "/" in path.

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.

Correction from self-review round two: at 3fe812a, ls("s3://bucket/?versionId=v1") of an empty bucket did not raise FileNotFoundError and did not send a HeadObject. The "/" in path fallback called _head_object(), which raises ValueError("The path has no key") before any request (checked with a stubbed client; the same held before this PR). The repair stands; its commit was reworded from 0de3190 to aaff8b1 (same tree) to state this, and the PR body now lists the ValueError → [] change.

if versions:
files = self._ls_object_versions(path)
else:
files = self._ls_dirs(path, refresh=refresh)
if not files and key:
file = self._head_object(path, refresh=refresh)
if file:
files = [file]
# An empty bucket lists nothing, while a missing bucket raises
# from the listing.
if not files and key:
raise FileNotFoundError(path)

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, deferrals.

  • The empty-files check appears twice (HeadObject fallback and raise). Kept: one raise covers both the versions and listing branches.
  • A missing key whose cached parent listing lacks it still costs ListObjectsV2 + HeadObject before raising. The same two requests were sent before this PR to return []; answering ls() from the parent listing changes cache semantics and is out of scope.
  • The breaking change is recorded as a release note in the PR body; docs/filesystem.md does not describe ls() of a missing path, so no docs change.

After the repair: just lint passed; offline test_s3.py 464 passed / 3 skipped; live test_ls_dirs + six *_directory_to_new_directory conformance tests 7 passed.

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 1a6a0d6, head aaff8b1 (tree identical to 0de3190). FINDINGS (claims only; no code change).

Claims checked with a stubbed client against this head (_serve_keys with dir/file, path bucket/dir/missing):

  • walk() (default) yields nothing; walk(on_error="raise"), listdir(), tree() raise FileNotFoundError — as the PR body states.
  • find → [], glob → [], du → 0, exists/isdir → False, info → FileNotFoundError, expand_path → ['bucket/dir/missing'], with zero ls() calls from these — confirms "do not call ls()".
  • AioS3FileSystem.walk/listdir/tree are fsspec's AbstractFileSystem methods (identity checked), so they follow the sync results; the async _walk() claim is from fsspec's asyn.py source (yields (path, [], []) on an omitted error), not run.
  • No request is added: the raise reads only the existing listing/HeadObject result.
  • Docstring Raises: a missing bucket raises from the listing (pre-existing translation of NoSuchBucket). No docs page describes ls() of a missing path (searched docs/filesystem.md, docs/s3fs.md, docs/aio.md).

CORRECTED: the PR body and the 0de3190 commit message said a bucket path with a version query "sends a HeadObject without a key" / was unchanged. Before this PR, ls("s3://bucket/?versionId=v1") of an empty bucket raised ValueError from _head_object() without a request; it now returns []. The commit was reworded (now aaff8b1, same tree, --force-with-lease), and the PR body lists this as a change.

Evidence limits: live S3 runs were local (test_ls_dirs + six conformance tests at 0de3190; the full conformance module at 3fe812a); the rest of just test pyathena runs in CI after Ready.

return list(files) if detail else [f.name for f in files]

def _ls_object_versions(self, path: str) -> list[S3Object]:
Expand Down
44 changes: 4 additions & 40 deletions tests/pyathena/filesystem/test_fsspec_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"""fsspec's conformance tests, run against the S3 filesystems on real S3."""

import contextlib
import functools
import posixpath
import uuid

Expand All @@ -21,33 +20,6 @@
from tests.pyathena.conftest import connect


def _xfail_missing_ls(test):
"""Mark an inherited conformance test as failing on GH-1330.

The test is expected to fail with pytest's ``Failed``, as the
``pytest.raises(FileNotFoundError)`` around ``ls()`` does when nothing is
raised, so other errors still fail it. The mark goes on a wrapper, so the
test function of fsspec's class stays unmarked.

Args:
test: The test function of fsspec's abstract class.

Returns:
The wrapped test, expected to fail.
"""

@functools.wraps(test)
def wrapper(*args, **kwargs):
return test(*args, **kwargs)

return pytest.mark.xfail(
reason="GH-1330: ls() of a missing key prefix returns [] instead of raising "
"FileNotFoundError",
raises=pytest.fail.Exception,
strict=True,
)(wrapper)


class S3Fixtures(abstract.AbstractFixtures):
@pytest.fixture(scope="class")
def fs(self):
Expand Down Expand Up @@ -88,19 +60,15 @@ def fs(self):


class TestS3FileSystemCopy(abstract.AbstractCopyTests, S3Fixtures):
test_copy_directory_to_new_directory = _xfail_missing_ls(
abstract.AbstractCopyTests.test_copy_directory_to_new_directory
)
pass


class TestS3FileSystemGet(abstract.AbstractGetTests, S3Fixtures):
pass


class TestS3FileSystemPut(abstract.AbstractPutTests, S3Fixtures):
test_put_directory_to_new_directory = _xfail_missing_ls(
abstract.AbstractPutTests.test_put_directory_to_new_directory
)
pass


class TestS3FileSystemPipe(abstract.AbstractPipeTests, S3Fixtures):
Expand All @@ -112,16 +80,12 @@ class TestS3FileSystemOpen(abstract.AbstractOpenTests, S3Fixtures):


class TestAioS3FileSystemCopy(abstract.AbstractCopyTests, AioS3Fixtures):
test_copy_directory_to_new_directory = _xfail_missing_ls(
abstract.AbstractCopyTests.test_copy_directory_to_new_directory
)
pass


class TestAioS3FileSystemGet(abstract.AbstractGetTests, AioS3Fixtures):
pass


class TestAioS3FileSystemPut(abstract.AbstractPutTests, AioS3Fixtures):
test_put_directory_to_new_directory = _xfail_missing_ls(
abstract.AbstractPutTests.test_put_directory_to_new_directory
)
pass
41 changes: 35 additions & 6 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,13 @@ def test_info_does_not_use_listing_of_path(self):
pytest.param(
False, lambda fs: fs.exists("s3://bucket/d/key", refresh=True), id="exists"
),
pytest.param(False, lambda fs: fs.ls("s3://bucket/d/key", refresh=True), id="ls"),
pytest.param(
False,
lambda fs: pytest.raises(

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, finding 2 of 2.
FINDING: test_missing_object_drops_cached_parent_listing wrapped every lookup in contextlib.suppress(FileNotFoundError), so the ls case no longer asserted the raise and an unexpected FileNotFoundError from exists/isfile would have been hidden. Repaired in 0de3190: only the ls parameter expects the error with pytest.raises.

FileNotFoundError, fs.ls, "s3://bucket/d/key", refresh=True
),
id="ls",
),
# The listed entry has no version, so it is looked up again.
pytest.param(True, lambda fs: fs.isfile("s3://bucket/d/key"), id="version_aware"),
],
Expand Down Expand Up @@ -665,13 +671,24 @@ def test_ls_empty_refresh_evicts_cached_listing(self):

self._serve_keys(fs, ())

assert fs.ls("s3://bucket/dir", refresh=True) == []
with pytest.raises(FileNotFoundError):
fs.ls("s3://bucket/dir", refresh=True)
# The next listing is requested again instead of returning the
# deleted object from the cache.
fs._call.reset_mock()
assert fs.ls("s3://bucket/dir") == []
with pytest.raises(FileNotFoundError):
fs.ls("s3://bucket/dir")
assert fs._call.call_args_list[0].args[0] is fs._client.list_objects_v2

@pytest.mark.parametrize("path", ["s3://bucket", "s3://bucket/?versionId=v1"])
def test_ls_empty_bucket(self, path):
# An existing bucket without objects is listed as empty, unlike a key
# path that matches nothing.
fs = self._make_fs()
self._serve_keys(fs, ())

assert fs.ls(path) == []

def test_find_withdirs_does_not_modify_cached_listing(self):
fs = self._make_fs()
fs.dircache[("bucket/dir", "")] = [self._file_object("dir/sub/file")]
Expand Down Expand Up @@ -900,9 +917,11 @@ def call(method, **kwargs):

fs._call.side_effect = call

assert fs.ls("s3://bucket/key", refresh=True) == []
with pytest.raises(FileNotFoundError):
fs.ls("s3://bucket/key", refresh=True)
# The next lookups must not return the deleted object and bucket from the cache.
assert fs.ls("s3://bucket/key") == []
with pytest.raises(FileNotFoundError):
fs.ls("s3://bucket/key")
assert not fs.exists("s3://bucket/key")
with pytest.raises(FileNotFoundError):
fs.info("s3://bucket", refresh=True)
Expand Down Expand Up @@ -4113,6 +4132,14 @@ def test_ls_versions(self):
assert actual[2].size == 2
assert fs.ls("s3://bucket/path", versions=True) == [f.name for f in actual]

def test_ls_versions_missing_path(self):
fs = self._make_fs()
fs.version_aware = True
fs._call.return_value = {"IsTruncated": False}

with pytest.raises(FileNotFoundError):
fs.ls("s3://bucket/path/missing", versions=True)

def test_ls_versions_object_path_falls_back_to_the_key(self):
fs = self._make_fs()
fs.version_aware = True
Expand Down Expand Up @@ -4855,7 +4882,9 @@ def test_ls_dirs(self, fs):

assert len(fs.ls(f"{dir_}/prefix")) == 5
assert len(fs.ls(f"{dir_}/prefix/")) == 5
assert len(fs.ls(f"{dir_}/prefix/test_")) == 0
# The start of an object's key is neither an object nor a directory.
with pytest.raises(FileNotFoundError):
fs.ls(f"{dir_}/prefix/test_")
assert len(fs.ls(f"{dir_}/prefix2")) == 1

test_1 = fs.ls(f"{dir_}/prefix/test_1")
Expand Down
Loading