Repository navigation
Raise FileNotFoundError from ls() of a missing key path (GH-1330) #1347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3fe812a
aaff8b1
a8a020a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| 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 | ||
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one (behavior and implementation), finding 1 of 2. FINDING: the new raise decided "key path" with
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correction from self-review round two: at 3fe812a, |
||
| 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) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one, deferrals.
After the repair:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
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, Evidence limits: live S3 runs were local ( |
||
| return list(files) if detail else [f.name for f in files] | ||
|
|
||
| def _ls_object_versions(self, path: str) -> list[S3Object]: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one, finding 2 of 2. |
||
| 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"), | ||
| ], | ||
|
|
@@ -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")] | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
||
There was a problem hiding this comment.
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'swalk,_walk,listdir,treeand 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:
pyathena/filesystem/s3.py:551: the newRaisestext conflates an S3 key prefix with a directory. With onlybucket/p/test_0,ls("bucket/p/test_")raises althoughp/test_is a key prefix of an existing key; the test comment attests/pyathena/filesystem/test_s3.py:4885says the same.Pre-existing, not introduced by the diff (both reproduced by the author with a stubbed client):
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)raisesFileNotFoundError, and the nextls(bucket)returns the deleted entries.pyathena/filesystem/s3.py:572: withbucket/key/childpresent and no versionv1ofbucket/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.There was a problem hiding this comment.
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
Raisestext now says a key path that "is neither an object nor a directory, a path that some key starts with followed by/", and thetest_ls_dirscomment 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 lintpassed. Round two: the wording matches_ls_dirs()(listskey/), the exact-object HeadObject fallback,_ls_object_versions()(same/boundary, then exact-key versions), and adir/marker (a key that starts withdir/). 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 searcheskey/, then falls back to the exact object. Thusp/test_0alone does not makep/test_listable; the revised wording and fixture comment correctly describe this";versions=True, adir/-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.