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
48 changes: 44 additions & 4 deletions pyathena/filesystem/s3_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@
"Metadata": "metadata",
"LastModified": "last_modified",
}
# Fields read as None through attribute access when the object does not have them.
_S3_OBJECT_FIELDS = frozenset(

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 and operational behavior): FINDINGS (PR description only, corrected)

Base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe, head 2b45c152535c0eb36fef3802adda9e341bdafa89. This is a full claim pass over the PR body, the commit message, and the changed docstrings and comments.

Claims checked:

  • "Known fields = mapped property names + name/type/bucket/key/size/version_id/is_latest": matches _S3_OBJECT_FIELDS (s3_object.py:37-48). These are the only kwargs that s3.py passes to S3Object (type, bucket, key, version_id, is_latest), plus the name and size that __init__ sets.
  • "Dunder names raise AttributeError": no dunder name is in the set. hasattr(o, "__setstate__") is now False (asserted in test_attribute).
  • "setstate resolved to None": on the base, the copy and pickle failures are TypeError: 'NoneType' object is not callable, which matches the __setstate__ call in copy._reconstruct and the unpickler.
  • "DirFileSystem/GenericFileSystem call .copy() on each entry": in fsspec 2026.9.0, implementations/dirfs.py:105, 307, 313, 322, 334 and generic.py:202, 216, 231.
  • "pyathena/ reads optional fields only through .get()": re-grepped at this head. The attribute and subscript reads are name, type and key only.
  • "AioS3FileSystem builds its entries through the synchronous filesystem": s3_async.py constructs no S3Object; _info/_ls delegate to _sync_fs.
  • Corrected: the PR body said that f.is_latest on ls(versions=True) entries is "as documented in docs/filesystem.md". The version.is_latest loop at docs/filesystem.md:155-157 iterates object_version_info() (S3ObjectVersion), not ls() entries. The reader that actually exists is test_ls_versions (tests/pyathena/filesystem/test_s3.py:810), and the PR body now cites that test.
  • Evidence scope: the PR body now separates manual checks (AioS3FileSystem/GenericFileSystem under fsspec wrappers; copy/pickle on 3.11 and 3.14) from automated tests, and states that PR CI runs the newest Python only.

Existing caller: code that relied on entry["missing"] is None, on "x" in entry being always True, or on entry.unknown is None now gets KeyError, False or AttributeError. The PR body lists these as release-note behavior changes. getattr(entry, name, default) and entry.get(name) keep working.

Documentation reader: docs/api/filesystem.rst:49 autodocs S3Object, so the updated class docstring and copy() are rendered there. No prose in docs/ describes the old None behavior.

AWS operator: no request changes. test_dir_filesystem is offline (2 mocked calls).

[
*_API_FIELD_TO_S3_OBJECT_PROPERTY.values(),
"name",
"type",
"bucket",
"key",
"size",
"version_id",
"is_latest",
]
)


class S3ObjectType:
Expand Down Expand Up @@ -95,7 +108,10 @@ class S3Object(MutableMapping[str, Any]):

The object supports both dictionary-style access and property-style
access to metadata fields like content type, storage class, encryption
settings, and object lock configurations.
settings, and object lock configurations. Dictionary-style access
behaves like a dictionary, so a missing key raises KeyError. Property-style
access returns None for a known field that the object does not have,
and raises AttributeError for any other missing name.

Example:
>>> s3_obj = S3Object({"ContentType": "text/csv", "ContentLength": 1024})
Expand Down Expand Up @@ -163,10 +179,26 @@ def get(self, key: str, default: Any = None) -> Any:

@override
def __getitem__(self, item: str) -> Any:
return self.__dict__.get(item)
return self.__dict__[item]

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): CLEAN

Base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe, head 2b45c152535c0eb36fef3802adda9e341bdafa89. Scope: git diff aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe..2b45c152535c0eb36fef3802adda9e341bdafa89 (3 files).

Covered:

  • Behavior and failure paths:
    • Every attribute or subscript read of an entry under pyathena/ (s3.py:526, 639-640, 706-708, 801, 846-847, 2028-2031; s3_async.py:339-340) touches name, type or key, which every construction path sets.
    • Optional fields are read through .get() (s3.py:648-649, the copy and checksum paths), so the new KeyError and get() defaults do not change their results.
    • if cache: in info() uses __len__, which is unchanged.
    • No module outside pyathena/filesystem/ reads entries.
  • Copy and pickle: copy.copy, copy.deepcopy, pickle protocols 0 and default, and copy() all round-trip on Python 3.11 (the floor) and 3.14. __setstate__ now raises AttributeError, so they fall back to __dict__.update().
  • Framework contracts: checked manually against fsspec 2026.9.0 with mocked _call:
    • DirFileSystem over AioS3FileSystem works for info() and ls(detail=True), both synchronously and with asynchronous=True (_info/_ls).
    • GenericFileSystem(default_method="current") works for info() and ls(detail=True).
    • With the base s3_object.py, all of these raise TypeError.
  • Test quality: all 7 new tests fail on the base. test_dir_filesystem also asserts that the cached entries keep their full names, which would fail if copy() returned self.

Note: _ls_dirs caches listings under (path, delimiter) (documented at s3.py:420-421), and info() looks up dircache[path] and its parent. So DirFileSystem.info() after ls() sends a HeadObject, and test_dir_filesystem mocks that response instead of relying on the listing cache.

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.

Rebase onto master (no code change)


def __getattr__(self, item: str):
return self.get(item)
def __getattr__(self, item: str) -> Any:
"""Return None for a known field that the object does not have.

Called only when normal attribute lookup fails, so fields that the
object has are returned without reaching this method.

Args:
item: The attribute name.

Returns:
None, if ``item`` is a known S3 object field.

Raises:
AttributeError: If ``item`` is not a known S3 object field.
"""
if item in _S3_OBJECT_FIELDS:
return None
raise AttributeError(f"{type(self).__name__!r} object has no attribute {item!r}")

@override
def __setitem__(self, key: str, value: Any) -> None:
Expand All @@ -192,6 +224,14 @@ def __len__(self) -> int:
def __str__(self):
return str(self.__dict__)

def copy(self) -> S3Object:

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): CLEAN

  • Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only --ephemeral, session 01a100d0-b30d-7ef1-8b69-d5aca3e0e61a.
  • Scope: base aa0fc9146f4e683d6cdf96b894f963f9dd8f7abe, head 2b45c152535c0eb36fef3802adda9e341bdafa89, in a clean detached snapshot at the head.
  • Package: the literal diff, the intended behavior, and repository conventions, plus read-only access to the installed fsspec 2026.9.0 sources. It did not include the PR number, the PR description, the commit message, or the self-review findings.
  • Constraints: review only; no edits, builds, tests, network or GitHub access.
  • Both the snapshot and the PR worktree were unchanged afterwards (git status clean, HEAD 2b45c152).

This is a static review: the reviewer executed nothing.

Reviewer output (verbatim):

Covered:

  • S3Object item/attribute access, known-field completeness, mutation overrides, Mapping mixins, equality, and truthiness.
  • copy(), shallow/deep copying, and pickle reconstruction, including interaction with __setattr__ and missing protocol attributes on Python ≥3.11.
  • All entry consumers found under pyathena/, including synchronous/asynchronous listing, caching, version handling, file reads/appends, copying, checksums, and timestamps.
  • Installed fsspec 2026.9.0 contracts in implementations/dirfs.py, generic.py, spec.py, and asyn.py, including copy-and-rename cache isolation.
  • New tests’ observable assertions and expected failures against the old implementation; changed docstrings, import placement, and helper naming.

CLEAN

No actionable defects introduced by this diff found. No caller requires an omitted optional attribute field. The new tests exercise the previous failures and check that renaming copied entries preserves cached names.

Static review only; no tests, builds, or network access. Checkout remains clean at 2b45c152535c0eb36fef3802adda9e341bdafa89.

"""Return a shallow copy of the object.

Returns:
A new S3Object with the same fields.
"""
return copy.copy(self)

def to_dict(self) -> dict[str, Any]:
"""Convert S3Object to dictionary representation.

Expand Down
28 changes: 28 additions & 0 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import pytest
from fsspec import Callback
from fsspec.dircache import DirCache
from fsspec.implementations.dirfs import DirFileSystem

import pyathena
from pyathena.filesystem import register_s3_filesystem
Expand Down Expand Up @@ -1446,6 +1447,33 @@ def test_ls_versions_object_path_falls_back_to_the_key(self):
("bucket/path/key", "v1", 2),
]

def test_dir_filesystem(self):
# DirFileSystem copies every entry with copy() before renaming it.
fs = self._make_fs()
fs._call.side_effect = [
{
"CommonPrefixes": [{"Prefix": "path/dir/"}],
"Contents": [{"Key": "path/key", "Size": 4}],
"IsTruncated": False,
},
{"ContentLength": 4, "ETag": '"etag"'},
]
dir_fs = DirFileSystem(path="bucket/path", fs=fs)

actual = dir_fs.ls("", detail=True)
assert [(f["name"], f["type"]) for f in actual] == [("dir", "directory"), ("key", "file")]
assert all(isinstance(f, S3Object) for f in actual)
actual = dir_fs.info("key")
assert isinstance(actual, S3Object)
assert (actual.name, actual.size) == ("key", 4)
# The cached entries keep their full names.
assert [f.name for f in fs.ls("bucket/path", detail=True)] == [
"bucket/path/dir",
"bucket/path/key",
]
assert fs.info("bucket/path/key").name == "bucket/path/key"
assert fs._call.call_count == 2

def test_metadata_with_version_id(self):
fs = self._make_fs()
fs._call.return_value = {"Metadata": {}}
Expand Down
59 changes: 59 additions & 0 deletions tests/pyathena/filesystem/test_s3_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
#
# SPDX-License-Identifier: MIT

import copy
import pickle
from datetime import datetime

import pytest

from pyathena.filesystem.s3_object import (
S3CompleteMultipartUpload,
S3Metadata,
Expand Down Expand Up @@ -103,6 +107,61 @@ def test_to_api_repr(self):
"StorageClass": "STANDARD",
}

@staticmethod
def _file_object():
return S3Object(
init={"ContentLength": 3, "ETag": '"etag"'},
type=S3ObjectType.S3_OBJECT_TYPE_FILE,
bucket="test-bucket",
key="path/to/object",
)

def test_mapping(self):
actual = self._file_object()
assert actual["etag"] == '"etag"'
with pytest.raises(KeyError):
actual["version_id"]
assert "etag" in actual
assert "version_id" not in actual
assert actual.get("etag") == '"etag"'
assert actual.get("version_id") is None
assert actual.get("version_id", "default") == "default"
assert actual.pop("version_id", "default") == "default"
assert actual.setdefault("version_id", "v1") == "v1"
assert actual["version_id"] == "v1"

def test_attribute(self):
actual = self._file_object()
assert actual.etag == '"etag"'
# Known fields that the object does not have read as None.
assert actual.version_id is None
assert actual.is_latest is None
assert actual.content_type is None
assert "content_type" not in actual
with pytest.raises(AttributeError):
_ = actual.unknown
assert not hasattr(actual, "__setstate__")

@pytest.mark.parametrize(
"func",
[
copy.copy,
copy.deepcopy,
lambda obj: pickle.loads(pickle.dumps(obj)),
lambda obj: obj.copy(),
],
ids=["copy", "deepcopy", "pickle", "copy_method"],
)
def test_copy(self, func):
expected = self._file_object()
actual = func(expected)
assert isinstance(actual, S3Object)
assert actual is not expected
assert actual == expected
assert actual.name == "test-bucket/path/to/object"
actual["name"] = "renamed"
assert expected.name == "test-bucket/path/to/object"


class TestS3Metadata:
def test_init(self):
Expand Down
Loading