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
127 changes: 93 additions & 34 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,9 @@ def _head_object(

The result is cached under the path, or under the version-qualified
path for an explicit version. An explicitly requested ``"null"``
version is not cached. A missing object evicts its entry.
version is not cached. A missing object evicts its entry and, unless
a version was requested, the cached listing of its parent that still
lists it.

Args:
path: The object path, optionally with a versionId query.
Expand Down Expand Up @@ -394,6 +396,13 @@ def _head_object(
)
except FileNotFoundError:
self._evict_cache(path)
if not version_id:
# Evict the cached listing of the parent only if it still
# lists the path.
parent_key = (self._parent(path), "/")
files = self.dircache.get(parent_key)
if files and any(f.name == path for f in files):
self._evict_cache(parent_key)
return None
if self.version_aware and not version_id:
# Pin the version of the object so that subsequent reads see
Expand Down Expand Up @@ -628,12 +637,18 @@ def info(self, path: str, **kwargs) -> S3Object:
"""Return information about an S3 path.

Uses the directory cache first: a cached entry for the path is
returned, a cached listing of the path itself makes it a directory,
and a cached listing of its parent without it means it does not exist.
returned, the entry of the path in a cached listing of its parent is
returned, preferring an object to a key prefix of the same name as
HeadObject does, and a cached listing of its parent without it means
it does not exist.
The cached bucket listing holds only the buckets that the caller owns,
so a bucket missing from it is looked up with HeadBucket.
Otherwise, a key path is looked up with HeadObject and, if no object
exists, with a ListObjectsV2 request (``Delimiter="/"``,
``MaxKeys=1``) that checks whether it is a key prefix; a bucket path
is looked up with HeadBucket. With ``version_aware``, a cached file
is looked up with HeadBucket. If these requests find a listed object
missing, or find a key prefix, the cached listing of the parent is
removed. With ``version_aware``, a cached file
entry without a version ID is looked up again. With an explicit
version, the cached entries of the path are skipped, and the
HeadObject result is cached under the version-qualified path apart
Expand All @@ -647,16 +662,16 @@ def info(self, path: str, **kwargs) -> S3Object:
version_id: The version ID to look up when the path has none.

Returns:
S3Object describing the bucket, directory, or file.
S3Object describing the bucket, directory, or file. The root path
(``""``, ``"/"`` or ``"s3://"``) is a directory.

Raises:
FileNotFoundError: If the path does not exist.
"""
refresh = kwargs.pop("refresh", False)
path = self._strip_protocol(path)
bucket, key, path_version_id = self.parse_path(path)
version_id = path_version_id if path_version_id else kwargs.pop("version_id", None)
if path in ["/", ""]:
# parse_path rejects the root path.
return S3Object(
init={
"ContentLength": 0,
Expand All @@ -666,17 +681,24 @@ def info(self, path: str, **kwargs) -> S3Object:
"LastModified": None,
},
type=S3ObjectType.S3_OBJECT_TYPE_DIRECTORY,
bucket=bucket,
bucket="",
key=None,
version_id=None,
)
bucket, key, path_version_id = self.parse_path(path)
version_id = path_version_id if path_version_id else kwargs.pop("version_id", None)
# Cached entries describe the current version of a path, so an
# explicit version uses only the HeadObject cache of that version.
if not refresh and not version_id:
caches: list[S3Object] | S3Object | None = self._ls_from_cache(path)
if caches is not None:
if isinstance(caches, list):
cache = next((c for c in caches if c.name == path), None)
matches = [c for c in caches if c.name == path]
# A key can be both an object and a key prefix.

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 2 (claims, callers, operations) — base 9b2f033708b81dce7323dfda61e8b2ec95df23da, head 2efc5a10e90a8332d998ba6159a0a673b16c96a7.

Claims checked:

  • "Tuple keys since v3.15.0": git tag --contains 681e7496 → first tag is v3.15.0. ✔
  • "isdir/isfile/size/du call info()": fsspec 2026.9.0 isdir/isfile/size call info(). du calls find() and isdir(), and reaches info() only through _find's object fallback. Corrected the PR body to name only isdir()/isfile()/size().
  • "s3fs behaves the same way" (stale parent listing): s3fs 2026.9.0 _info uses fsspec _ls_from_cache, which raises FileNotFoundError from a parent listing without the path. ✔ s3fs also answers "directory" from a listing of the path itself, which this PR deliberately does not do.
  • "ListBuckets returns only owned buckets" (docstring/comment): AWS ListBuckets doc, "buckets owned by the authenticated sender". ✔
  • HeadBucket 403/404 statements: measured live with boto3 on 2026-10-03. ✔ "With invalid credentials the next request fails with PermissionError": measured ls/pipe/info → PermissionError after exists() returned True. ✔
  • docs/filesystem.md:101-104 ("info/isfile/open treat dir/ as dir: the object dir if it exists, otherwise the directory"): still holds with a cached parent listing, because the object entry is preferred (this line). It would not have held if the path's own listing were used. No other docs mention the listing cache or exists() of buckets. The 403 → PermissionError table stays true for the other operations.

Adversarial callers:

  • A key with // has a parent with a trailing slash, which never matches a stripped listing key, so it falls through to HeadObject (no false FileNotFoundError).
  • A version-qualified path never uses listings.
  • A folder marker dir/ is listed as a CommonPrefix of the parent, so it does not shadow the object dir.
  • External writers after a cached listing are recorded as a release-note behavior change (round 1).
  • The stale-write race is out of scope by decision.

Evidence: the live tests/pyathena/filesystem run (422 passed) was local at this head. Draft CI ran only check/lint/offline (all pass); AWS jobs run on Ready. The exists() 403 path is offline-only.

Result: FINDINGS (1, PR-text only, repaired). No code change.

cache = next(
(c for c in matches if c.type == S3ObjectType.S3_OBJECT_TYPE_FILE),
next(iter(matches), None),
)
elif caches.name == path:
cache = caches
else:
Expand Down Expand Up @@ -720,6 +742,9 @@ def info(self, path: str, **kwargs) -> S3Object:
or response.get("Contents", [])
or response.get("CommonPrefixes", [])
):
# Nothing caches the key prefix, and the cached listing of the
# parent may predate it.
self._evict_cache((self._parent(path), "/"))
return self._directory_object(bucket, key.rstrip("/") if key else None, version_id)
raise FileNotFoundError(path)

Expand Down Expand Up @@ -898,7 +923,8 @@ def exists(self, path: str, **kwargs) -> bool:
refresh: If True, bypass the cache and query S3.

Returns:
True if the path exists, False otherwise.
True if the path exists, False otherwise. A bucket that HeadBucket
denies access to (403) exists.

Example:
>>> fs = S3FileSystem()
Expand All @@ -919,15 +945,14 @@ def exists(self, path: str, **kwargs) -> bool:
return bool(info)
except FileNotFoundError:
return False
if not refresh:
if self.dircache.get(bucket, False):
return True
try:
if self._ls_from_cache(bucket):
return True
except FileNotFoundError:
pass
file = self._head_bucket(bucket, refresh=refresh)
if not refresh and self._ls_from_cache(bucket):
return True
try:
file = self._head_bucket(bucket, refresh=refresh)
except PermissionError:
# HeadBucket answers 403 for a bucket that exists but that the
# caller may not access.
return True
return bool(file)

def rm_file(self, path: str, **kwargs) -> None:
Expand Down Expand Up @@ -1231,8 +1256,8 @@ def mkdir(self, path: str, create_parents: bool = True, **kwargs) -> None:
)
except botocore.exceptions.ParamValidationError as e:
raise ValueError(f"Bucket create failed {bucket!r}: {e}") from e
# invalidate_cache walks parent paths and never pops the root
# entry itself, so evict the cached bucket listing directly.
# invalidate_cache of the bucket keeps the cached bucket
# listing, so evict it directly.
self._evict_cache("")
self.invalidate_cache(bucket)
else:
Expand Down Expand Up @@ -1304,8 +1329,8 @@ def rmdir(self, path: str) -> None:
Bucket=bucket,
)
self.invalidate_cache(bucket)
# invalidate_cache walks parent paths and never pops the root
# entry itself, so evict the cached bucket listing directly.
# invalidate_cache of the bucket keeps the cached bucket listing,
# so evict it directly.
self._evict_cache("")

def touch(self, path: str, truncate: bool = True, **kwargs) -> dict[str, Any]:
Expand Down Expand Up @@ -2222,6 +2247,8 @@ def modified(self, path: str) -> datetime:
def invalidate_cache(self, path: str | None = None) -> None:
"""Remove the cached entries of the path and its parent paths.

The cached bucket listing is removed only by the root path (``""``,
``"/"`` or ``"s3://"``), not by the paths of buckets or keys.
A version-qualified path invalidates the version under every query
spelling that ``parse_path`` accepts, and also the object path without
the version, because deleting or copying a version can change the
Expand All @@ -2234,6 +2261,8 @@ def invalidate_cache(self, path: str | None = None) -> None:
self.dircache.clear()
else:
path = self._strip_protocol(path)
if not path:
self._evict_cache("")
while path:
# parse_path does not accept "?" in keys, so it starts the
# versionId query.
Expand Down Expand Up @@ -2272,17 +2301,38 @@ def _evict_cache(self, key: str | tuple[str, str]) -> None:
def _ls_from_cache(self, path: str) -> list[S3Object] | S3Object | None:
"""Check the dircache for a cached entry of the path.

fsspec's implementation assumes every dircache value is a listing,
but S3FileSystem also caches a single S3Object under the object's own
path (HeadObject/HeadBucket results). Guard the parent lookup so that
looking up a child path of a cached object does not fail, and fall
through to the S3 API instead.
fsspec's implementation looks up listings under the path itself, but
S3FileSystem caches a single S3Object under the path of an object or
a bucket (HeadObject/HeadBucket results), the bucket listing under
``""``, and the other listings under ``(path, delimiter)`` (see
``_ls_dirs``).

Args:
path: The path without the protocol.

Returns:
The cached entry of the path, the entries of a cached parent
listing named as the path, or None if no cached entry describes
the path. A listing of the path itself is not used, because it
cannot tell whether an object of the same name exists. A
version-qualified path uses only its own entry, because listings
describe the current versions.

Raises:
FileNotFoundError: If a cached listing of the parent directory of
a key path does not contain the path.
"""
cache = self.dircache.get(path.rstrip("/"))
if cache is not None:
return cast("list[S3Object] | S3Object", cache)
parent_cache = self.dircache.get(self._parent(path))
if isinstance(parent_cache, list):
_, key, version_id = self.parse_path(path)
if version_id:
return None
if key:
parent_cache = self.dircache.get((self._parent(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 1 (implementation behavior) — base 9b2f033708b81dce7323dfda61e8b2ec95df23da, head 2efc5a10e90a8332d998ba6159a0a673b16c96a7.

Covered: _ls_from_cache(), info(), exists(), invalidate_cache(), the mkdir/rmdir comments, and all tests changed in tests/pyathena/filesystem/test_s3.py. Traced the info() callers that now get listing entries: S3File.__init__ (size, ETag for IfMatch, version pin under version_aware), cat_file() negative ranges (info.key != key guard), cp_file() size, checksum() ETag, modified(). Also checked AioS3FileSystem._info/_exists, which delegate to the sync methods. The cached listing keys come only from ls()/_find(), which pass protocol-stripped paths, so (self._parent(path), "/") matches them. Prefixed and continuation listings are not cached, so a cached parent listing is complete.

Write paths: pipe_file, put_file, touch, cp_file, setxattr, _delete_objects_request, S3File.commit and the aio _put_file/_cp_file all call invalidate_cache(path), which evicts (parent, "/") and (parent, "") up to the bucket. So writes through this instance never leave a parent listing that misses the new object.

Result: FINDINGS (1).

  1. Release-note completeness. Before this change, a stale listing affected only ls()/find(). Now it also answers info()/exists()/isfile()/size()/open(), so an object that another writer adds after the listing was cached is reported missing until refresh=True/invalidate_cache(). This is the intended S3FileSystem.info() does not use cached directory listings #965 behavior and matches s3fs. Repair: added it to the PR body's behavior changes. No code change.

No other findings: the version-qualified guard, the version_aware re-head, the same-name preference (object over prefix) and the bucket fallback each have a test. The exists() 403 branch is covered offline only.

else:
parent_cache = self.dircache.get("")
if parent_cache is not None:
files = [
f
for f in parent_cache
Expand All @@ -2294,7 +2344,10 @@ def _ls_from_cache(self, path: str) -> list[S3Object] | S3Object | None:
]
if files:
return files
raise FileNotFoundError(path)
if key:
raise FileNotFoundError(path)
# The bucket listing holds only the buckets that the caller owns,
# so a bucket missing from it is looked up with HeadBucket.
return None

def _open(
Expand Down Expand Up @@ -2613,9 +2666,15 @@ def __init__(
self._details = info
if size is None:
size = info.get("size")
elif "a" in mode and fs.exists(path):
append_info = fs.info(path)
if append_info.get("size", 0) < fs.MULTIPART_UPLOAD_MIN_PART_SIZE:
elif "a" in mode:
# The rewritten object keeps the metadata of the existing one,
# which a cached listing entry lacks, so look up the object.
with contextlib.suppress(FileNotFoundError):
append_info = fs.info(path, refresh=True)

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, session 01a10169-e169-7793-aa8c-9e5591e0c2a0. Static review only, run with codex exec -s read-only on a detached snapshot at head 2efc5a10e90a8332d998ba6159a0a673b16c96a7 (merge-base 9b2f033708b81dce7323dfda61e8b2ec95df23da). The prompt included the diff range and the intended behavior, without the PR number, description or self-review records. Afterwards the snapshot was clean and still at the same HEAD.

Covered: info()/exists(), listing cache keys, object/prefix precedence, refresh and invalidation, bucket lookup/403, root paths, trailing/repeated slashes, folder markers, version queries, S3File reads/appends, cat_file, cp_file, checksum, find, DirFileSystem, async delegation, docstrings and tests.

Result: FINDINGS

  1. P2, introduced: append loses metadata after a cached listing. info() now returns a ListObjectsV2 entry, so S3File append (s3_additional_kwargs.update(append_info.to_api_repr())) omitted ContentType/Metadata from the rewritten object.
  2. P2, introduced: a refreshed missing key reappears from its parent cache. After ls("bucket/d") and an external delete, exists(..., refresh=True) is False, but _head_object evicts only the key entry, so the next exists() returns True from (bucket/d, "/").
  3. P2, pre-existing: info("")/info("/")/info("s3://") call parse_path("") before the root branch and raise ValueError (same on the merge-base).

The reviewer noted that the own-listing, version_aware and explicit-version tests also pass on the merge-base; they are compatibility guards.

Verification by the author: all three reproduced (3 also on master). Repairs for 1 and 2 are in 1b26dd1 (this line and the refresh eviction in info()). 3 is outside this PR's scope and is reported to the maintainer instead of being fixed here.

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 1b26dd1 — self-review of the repair, both perspectives (range: git range-diff 9b2f0337..2efc5a10 9b2f0337..1b26dd13; only the new commit was added).

Round 1 (behavior):

  • The append lookup is now info(path, refresh=True) with FileNotFoundError suppressed. A missing object gives a plain write, as exists() → False did. A prefix-only path still gets the directory object and then fails in cat(), as on master. Other errors (e.g. PermissionError) still propagate before the base initializer (test_open_append_lookup_failure, now stubbing info). A versioned append is rejected earlier (s3.py:2622).
  • info(refresh=True) of a key without a version evicts (parent, "/") before the lookup. Buckets are untouched (_head_bucket already evicts "" on 404), and version lookups do not touch listings. The internal version_aware re-head sets refresh after this point, so it does not evict.
  • New tests test_open_append_keeps_metadata_of_listed_object and test_refresh_drops_cached_parent_listing fail on 2efc5a1 and pass here.

Round 2 (claims/operations):

  • The info() docstring for refresh and the append comment match the code.
  • Request count: an append sends one HeadObject, the same as master's uncached exists() + cached info(). It sends one more only when a HeadObject result was already cached. Stated in the PR body.
  • An append that is discarded after the lookup has evicted the parent listing needlessly. The cost is one relist, and a committed append invalidates it anyway.

Validation at 1b26dd1: just lint passed; uv run --env-file .env pytest -q -n 4 tests/pyathena/filesystem 424 passed (live S3).

Result: CLEAN. An independent follow-up on the repair is requested next.

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 (relayed): Codex CLI 0.160.0, model gpt-6-astra, session 01a10176-1537-7db2-96ed-5340a7bf6509. Static, read-only review of repair 1b26dd13991f98d42d91a0f72ea89c1654eae10d (git range-diff 9b2f0337..2efc5a10 9b2f0337..1b26dd13). Afterwards the snapshot was clean and still at the same HEAD.

Result: FINDINGS. Both original regressions are fixed and both new tests fail on 2efc5a1. One P2 remains, introduced relative to the merge-base: the parent-listing eviction applied only to info(refresh=True). ls("bucket/d/key", refresh=True) of a deleted object (which calls _head_object() directly) and the version_aware re-head both left ("bucket/d", "/") in place, so a later exists() returned True.

Repair fed2d2a0ec728c4539d38a556635e4cbeb6e7e6c moves the eviction to where the contradiction is found:

  • _head_object() 404 without a version evicts (parent, "/") only if that listing still lists the path (mirrors _head_bucket() with ""). So ls() of a nonexistent prefix keeps a correct parent listing.
  • info()'s ListObjectsV2 check evicts the parent listing when it finds a key prefix, because nothing caches the prefix and the listing may predate it (a refreshed lookup would otherwise be followed by FileNotFoundError from the stale listing).
  • The info(refresh=True) eviction from 1b26dd1 is removed.

Self-review of the repair:

  • Round 1: every key-path _head_object() caller (ls() at s3.py:563, info()) gets the eviction. Version-qualified misses keep listings. The non-refresh info() path reaches HeadObject with a listing cached only for the version_aware re-head, and the prefix branch only after a HeadObject miss.
  • Round 2: the _head_object()/info() docstrings and the PR body now describe the actual eviction points.

Tests: test_missing_object_drops_cached_parent_listing[exists|ls|version_aware] and test_refreshed_prefix_drops_cached_parent_listing fail on 2efc5a1. [ls] and [version_aware] also fail on 1b26dd1. test_missing_object_keeps_cached_parent_listing_without_it guards the conditional eviction. just lint passed; live tests/pyathena/filesystem 428 passed at fed2d2a. A second independent follow-up is requested next.

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.

Second independent follow-up (relayed): Codex CLI 0.160.0, model gpt-6-astra, session 01a1017d-e4f4-7672-b57f-e66cdce4d0c3. Static, read-only review of repair fed2d2a0ec728c4539d38a556635e4cbeb6e7e6c (git range-diff 9b2f0337..1b26dd13 9b2f0337..fed2d2a0). Afterwards the snapshot was clean and still at the same HEAD.

Covered: _head_object(), cache helpers, ls(), info(), exists(), find(), S3File append, AioS3FileSystem delegation and the tests. Also checked name normalization, explicit/null versions, prefix-only paths, eviction scope, request counts and docstrings against the merge-base.

Result: CLEAN.

  • Both stale-listing paths are fixed by the shared eviction in _head_object().
  • A missing object that the listing does not list keeps the listing, and a discovered prefix evicts it.
  • The eviction adds no S3 requests.
  • The new ls/version_aware cases fail on 1b26dd1. The prefix and non-matching-entry tests guard behavior that moved.
  • The fetch/invalidation race stays out of scope as agreed.

Independent review complete for head fed2d2a.

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 eebe98d099d4328a8529ce6642edd0117bcbb813: pre-existing finding 3 folded in at the maintainer's request. info() now checks the root path before parse_path().

Self-review of the repair:

  • Round 1 (behavior): info("")/("/")/("s3://") return the root directory object (bucket="", name "") without requests, so fsspec isdir() is True, isfile() False and size() 0. Before, the first two raised ValueError. exists() already returned True for the root. Non-root paths are parsed exactly as before. AioS3FileSystem._info delegates to this method. refresh/version_id do not apply to the root.
  • Round 2 (claims): "never reached since 207c8f0" holds. In 207c8f0 and 4fbced8, parse_path() precedes the root check, and PATTERN_PATH requires a bucket of at least one character, so "" and "/" never matched. The docstring now states the root is a directory, and the PR body lists the change.

Tests: test_info_root[|/|s3://] fails on fed2d2a and passes here. just lint passed; live tests/pyathena/filesystem 431 passed at eebe98d. An independent follow-up is requested next.

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 (relayed): Codex CLI 0.160.0, model gpt-6-astra, session 01a101d5-146d-79c0-9d75-de6020255926. Static, read-only review of repair eebe98d099d4328a8529ce6642edd0117bcbb813 (git diff fed2d2a0..eebe98d0, git range-diff 9b2f0337..fed2d2a0 9b2f0337..eebe98d0; the earlier commits are unchanged). Afterwards the snapshot was clean and still at the same HEAD.

Covered: root normalization, S3Object construction, non-root parsing/version/cache paths, and fsspec 2026.9.0 stat/isdir/isfile/size/sizes/checksum/ukey/du. Also AioS3FileSystem._info delegation, DirFileSystem, root exists/ls/find/walk/glob/expand_path, the test, the docstring and the parser history.

Result: CLEAN.

  • The pre-existing defect is resolved, and non-root behavior is unchanged.
  • Root traversal stays consistent with the merge-base: find/wildcard glob/recursive expansion still reject the root. A literal root glob with detail=True now succeeds.
  • The unreachability claim holds: PATTERN_PATH is unchanged since 207c8f0 and requires a non-empty bucket.
  • test_info_root fails without the repair.

Independent review complete for head eebe98d.

if (
append_info is not None
and append_info.get("size", 0) < fs.MULTIPART_UPLOAD_MIN_PART_SIZE
):
# Too small to be a part of a multipart upload: rewritten
# from the buffer.
append_data = fs.cat(path)
Expand Down
Loading
Loading