Skip to content

Settle the response models, S3Object, and the listing cache #1341

Open
0 of 5 issues completed
Open

Description

@laughingman7743

Part of #1327 (design problems 4 and 5 and defects 22 and 23 of the audit report).

Summary

Settle the shape of the response models, of S3Object as fsspec's info entry, and of the listing cache.

Current state (766d79d)

  1. Two model styles in two modules: frozen dataclasses with from_response in s3_core.py (S3ObjectSummary, S3CommonPrefix, S3Bucket, pages, delete models) and property classes over raw dicts in s3_object.py (S3ObjectVersion, S3MultipartUpload, S3Owner, S3PutObject, S3MultipartUploadPart, S3CompleteMultipartUpload), with a circular import. S3ObjectVersion.name drops the version; checksum fields cover 11 algorithms on parts and 4 elsewhere.
  2. S3Object is a MutableMapping, not a dict: keys live in __dict__, so a key named keys, items, get, update or pop breaks dict(o) and o.keys() (defect 22); it has no repr and is not JSON-serializable; update() does not remap API names while __init__ does; directory and bucket entries carry None-valued keys that file entries omit. Building it costs 3.8 µs per listed key, against 1.1 µs for a dict subclass built from the typed page and 0.25 µs for a plain dict (about 3 s of CPU per million keys in find).
  3. S3Metadata is both the core's HeadObject result and the user-metadata mapping of fsspec's metadata(): an existing object without user metadata is falsy, and results of different objects compare equal to each other and to {}.
  4. S3Core results: three **params precedence rules (sent as given with TypeError on duplicates; arguments win; params win over the path in generate_presigned_url); copy_object, delete_object, replace_object_metadata return None and discard version and ETag; to_dict() of S3PutObject and S3CompleteMultipartUpload exposes _etag (returned by touch()).
  5. S3Path round trips: access-point ARN buckets cannot be parsed though the core accepts them; key="" re-parses as None; a listed key ending in a version-like query (data?versionId=1) parses as version 1 of data and cannot be addressed (defect 23).
  6. The listing cache is an ad-hoc key scheme (path, "", (path, "/"), (path, ""), (path, "lookups")) spread over about nine methods, and invalidate_cache re-enumerates every delimiter and query spelling. info() and ls(detail=True) return the cached S3Object by reference, so a caller's mutation changes the cache.

Proposal (for decision)

  1. S3Object as a dict subclass with attribute access, built directly from the typed page models (from_summary, from_version, from_prefix, root), which fixes defect 22, repr and JSON, and the per-entry cost.
  2. One module and one style for the response models; typed results for copy and delete; a typed HeadObject result with a user_metadata mapping, while fs.metadata() keeps returning the mapping.
  3. One **params rule across S3Core.
  4. An escape for keys that end in a version-like query, or a documented limit.
  5. A typed cache object over fs.dircache that owns the key scheme and invalidation, shared by both filesystems; return copies or read-only entries from info()/ls().

Risks: isinstance(entry, dict) becomes true and the pickled form changes; S3Core.head_object's return type changes; tests that inspect fs.dircache (#1328).

Activity

  1. laughingman7743 commented on Nov 7, 2026

    @laughingman7743
    MemberAuthor

    Deferred from #1370 (recursive chmod, GH-1335), pre-existing, found by its independent review; they concern how paths and listed names are identified, so they belong with the response model here:

    1. Listed names reparsed as version paths. With the literal keys a/x and a/x?versionId=null (no versioning), find("bucket/a") names the second bucket/a/x?versionId=null; chmod(recursive=True) and rm(recursive=True) reparse that name as the null version of a/x, so a/x is targeted twice and the literal key not at all.
    2. bucket// is both the bucket and the key /. S3Path.parse("bucket//") keeps the key /, while find() strips it to the bucket. chmod("s3://bucket//", acl, recursive=True) updates the objects but skips PutBucketAcl (as on master).
    3. PathLike paths. chmod(Path("bucket/a"), ...) raises TypeError because S3Path.parse receives the Path before _strip_protocol converts it.
  2. laughingman7743 commented on Nov 7, 2026

    @laughingman7743
    MemberAuthor

    Design audit at 29cc1d3 (offline: stub client, no AWS), before deciding the proposal above. It re-checks the issue text and the three deferred items of the comment above, inventories the callers, and records the decisions. Codex reviewed a first draft; its factual corrections were reproduced and are folded in.

    Decided (2026-11-07): the recommended option of each item below. The sub-issues follow in the next comment.

    Current state (29cc1d3)

    Reproduced with a stub client unless marked "read".

    1. Models.
      • Two styles in two modules: frozen dataclasses with from_response in s3_core.py, property classes over raw dicts in s3_object.py. The import cycle exists only under TYPE_CHECKING (s3_object.py:14).
      • S3ObjectVersion("b", False, {"Key": "k", "VersionId": "v1"}).name is b/k.
      • Checksum properties: 10 on S3MultipartUploadPart, 4 on S3PutObject and S3CompleteMultipartUpload, none on S3Metadata. The installed botocore defines the same 10 algorithms (plus ChecksumType, except UploadPart) as optional output fields of PutObject, CompleteMultipartUpload, HeadObject, GetObject and the CopyObjectResult of CopyObject.
    2. S3Object.
      • Not a dict: isinstance(entry, dict) is False, repr() is the default <S3Object object at ...>, json.dumps raises.
      • Defect 22: after entry["keys"] = 1, dict(entry) raises TypeError: 'int' object is not callable; after entry["get"] = 1, entry.get("size") raises the same.
      • entry.update({"ContentType": "x"}) stores ContentType, while __init__ maps it to content_type.
      • Directory entries hold content_type, etag, last_modified and version_id as None; file entries omit the fields that the response lacks.
      • Cost on this machine: S3Object.from_summary 2.36 µs and copy() 1.20 µs; a prototype dict subclass 0.23 µs and its dict.copy() 0.07 µs (not a type-preserving copy).
      • Correction to the proposal: a dict subclass is JSON-serializable only for directory entries; file entries hold last_modified as a datetime, as s3fs's entries do.
    3. S3Metadata. bool(S3Metadata({"ContentLength": 5})) is False, and two objects without user metadata compare equal to each other and to {}. This is the documented mapping behavior of fs.metadata(); no caller in the package tests its truth value (read).
    4. S3Core parameters and results (read).
      • Four precedence rules: sent as given, with TypeError on a duplicate (head_object, copy_object, delete_object, tagging, ACL, listings); the method's fields win (get_object, put_object, the multipart methods, list_object_annotations); params win over retained fields but TypeError for the fields the copy sets (replace_object_metadata); params win over the path (generate_presigned_url). call() adds request_kwargs under the request's own parameters (s3_core.py:651); Send the authorization parameters of s3_additional_kwargs with every filesystem request #1372 extends which filesystem parameters reach it.
      • copy_object, delete_object and replace_object_metadata return None.
      • to_dict() of S3PutObject and S3CompleteMultipartUpload returns the private attribute names (_etag, ...), and touch() returns it (s3.py:1491; asserted in test_s3.py:1276). s3fs's touch returns the raw PutObject response.
    5. S3Path.
      • Access point ARNs do not parse (ValueError); s3fs 2026.9.0 splits arn:aws:s3:us-east-1:123456789012:accesspoint/ap/k into the ARN and k.
      • S3Path("b", "") prints b and re-parses with key None.
      • Defect 23 holds. s3fs has a related limit: split_path("bucket/data?versionId=1") drops the query and gives the key data.
    6. Listing cache.
      • Keys: path (HeadObject/HeadBucket), "" (buckets), (path, "/") and (path, "") (listings), (path, "lookups") (lookups with parameters). _invalidate_path (s3.py:2586) evicts four keys for each of the four query spellings of each ancestor.
      • info() and ls(detail=True) return cached entries by reference: after fs.info("bucket/a")["size"] = 999 the next info() returns 999; the same for ls. fsspec's find/walk/du do not mutate entries (read); s3fs's _lsdir also returns its cached list.
      • AioS3FileSystem shares the sync dircache (s3_async.py:161), and its _find calls the sync _find (s3_async.py:788).

    Deferred items of the comment above:

    • C1 (listed names reparsed). With the literal keys a/x and a/x?versionId=null in an unversioned bucket, find("bucket/a") names bucket/a/x?versionId=null, and:
      • chmod(recursive=True) updates a/x twice (once with VersionId=null) and never the literal key;
      • rm(recursive=True) deletes a/x and a/x version null, and leaves the literal key;
      • copy(recursive=True) raises ValueError: Cannot copy to a versioned file. after copying a/x;
      • mv(recursive=True) raises ValueError: Cannot move several paths to the same destination. before copying;
      • du (fsspec's, through info() of each name) counts a/x once under one name;
      • info("bucket/a/x?versionId=null") looks up a/x with VersionId=null (defect 23).
      • Traversal and invalidation lose identity too: with the key a/dir?versionId=v1/z, find("bucket/a") lists it but find("bucket/a", maxdepth=2) does not, since _find_levels (s3.py:930) re-parses the directory name; _invalidate_path (s3.py:2594) can take part of such a key for a version and skip the parent's listing.
    • C2 (bucket//). S3Path.parse("b//") is the key / with is_bucket True. find("bucket//") and recursive chmod("s3://bucket//") list the bucket; the recursive chmod sends no PutBucketAcl. rm_file("bucket//") deletes the key /. So the spelling is the key / for single-object operations and the bucket for listings.
    • C3 (PathLike) with pathlib.Path("bucket/a"): chmod, cat_file, rm_file, touch, metadata, sign, getxattr, setxattr, get_tags, object_version_info, cp_file, list_multipart_uploads and put_file (remote path) raise TypeError from S3Path.parse; rm raises TypeError: 'PosixPath' object is not iterable; glob raises AttributeError (endswith). info, exists, ls, find, du, size and checksum work through fsspec's _strip_protocol.

    Callers and compatibility surface

    Decisions

    1. S3Object as a dict subclass. Recommended: yes, with attribute access for known fields (None for an absent known field, AttributeError otherwise), attribute set/delete mapped to the entries, copy() returning an S3Object, to_dict()/to_api_repr() unchanged, and the S3Object(init, **kwargs) constructor still mapping API names. Entries keep their current key sets (directories keep their None-valued keys), so entry["etag"] does not change to KeyError. Breaking: isinstance(entry, dict) becomes True and the pickled form changes (release note).
    2. Cached entries. Recommended: every boundary that returns entries (info, ls/find/glob/walk details, file details, aio _find) returns copies that also copy the nested metadata mapping; the cost is measured on the type-preserving copy before merging.
    3. Response models, compatible part. Recommended: one module of models with one style, re-exported under the current names, constructors still accepting response dictionaries; checksum properties per operation as botocore defines them; S3ObjectVersion gains a typed path (its name stays bucket/key). Whether pickles of the property classes must still load is part of this decision; recommended: no (release note).
    4. HeadObject result. Recommended: keep S3Metadata for S3Core.head_object and fs.metadata() and document that its truth value and equality are those of the user metadata; no new type. A new record type would break the published {**head, ...} usage for little gain.
    5. Core results. Recommended: copy_object/replace_object_metadata return an S3CopyObject (destination version ID, source version ID, ETag, last modified, checksums) and delete_object an S3DeleteObject (version ID, delete marker), in place of None. touch() options: (a) unchanged, documented; (b) the raw PutObject response, as s3fs; (c) public field names. Recommended: (b) in the release with the other breaking changes, since (c) is breaking without gaining s3fs parity.
    6. One **params rule. Recommended principle: fields that a method derives from its arguments raise TypeError when also given in params; defaults the method fills in (retained metadata, request_kwargs) yield to params. The PR first tabulates each method's derived fields (including CopySourceRange, MultipartUpload, checksum and pagination fields), keeps documented fallbacks such as range_=None with Range=, and normalizes the writer's internal calls (s3_writer.py:219) that forward checksum parameters.
    7. Literal keys that look like version paths (defect 23, C1). Recommended: filesystem operations carry the listed entries' bucket, key and version ID through traversal (_find_levels), pairing, the fan-outs, lookups and invalidation, using a private sequence of typed paths; public find()/expand_path() still return strings. Addressing such a key by a string path stays a documented limit, as in s3fs, with S3Core and an explicit S3Path as the way to reach it; no escape syntax, since any spelling is itself a possible key.
    8. bucket// (C2). Options: (a) the bucket for every operation (removes rm_file's access to the key /); (b) keep the current split and document it, including that a recursive chmod of bucket// does not change the bucket ACL. Recommended: (b).
    9. PathLike (C3). Recommended: every public method converts os.PathLike with fsspec.utils.stringify_path before classifying the path (including rm's iterable check, glob and the pairing's isinstance(..., str)), without stripping slashes.
    10. S3Path details. Recommended: S3Path(bucket, "") normalizes the key to None. Access point ARNs as buckets become a separate enhancement issue, since parent, pairing and cache handling need ARN-aware buckets.
    11. Listing cache object. Recommended: one S3ListingCache over fs.dircache (still fsspec's DirCache, so use_listings_cache, listings_expiry_time, max_paths and empty-cache pickling keep working), shared by both filesystems, that owns the key scheme. It keeps today's behavior: the four version-query spellings, explicit version_id, trailing-slash and empty-segment variants, eviction of the bucket listing only when it lists the bucket, uncached null versions, no caching for prefix/token listings, per-parameter lookups with their own timestamps and hashed SSE-C keys, and the _ls_from_cache override. Tests that read dircache keys are rewritten in that PR, keeping their request/expiry/invalidation evidence.

    Split

    One sub-issue of this issue per PR:

  3. laughingman7743 commented on Nov 7, 2026

    @laughingman7743
    MemberAuthor

    Sub-issues of the decisions above, one PR each:

    1. Accept os.PathLike paths in every S3 filesystem method #1380 — os.PathLike paths in every method (decision 9). First; small and independent.
    2. Keep the identity of listed keys through traversal, fan-outs, and invalidation #1381 — identity of listed keys through traversal, fan-outs and invalidation; bucket// documented; key="" normalized (decisions 7, 8, 10). After Run the batch operations of S3FileSystem concurrently (GH-1322) #1344 merges.
    3. Make S3Object a dict subclass and return copies of cached entries #1382 — S3Object as a dict subclass and copies of cached entries (decisions 1, 2).
    4. Consolidate the S3 response models and settle the core results and parameter precedence #1383 — response models, core results, touch() and the **params rule (decisions 3–6).
    5. Own the listing cache key scheme and invalidation in one cache object #1384 — one listing cache object (decision 11). Interface settled before Remove the duplication between S3FileSystem and AioS3FileSystem #1340.

    Access point ARNs (decision 10) are split out to #1385.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions