Repository navigation
Translate delete-marker versions to FileNotFoundError and fix two S3Core edge cases (GH-1338) - #1345
Conversation
…ore edge cases (GH-1338) - A 405 response with the x-amz-delete-marker: true header, from HeadObject or GetObject of a version that is a delete marker, is translated to FileNotFoundError, so exists() returns False for it. Other 405 responses still map to EPERM. - S3Core.part_ranges() returns no ranges for an empty object instead of raising ValueError. - S3Core.call() with request_kwargs resolves the operation by the callable's __name__ and sends a callable without one, such as a functools.partial, without request_kwargs instead of raising AttributeError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-1338) - A functools.partial of a client method is sent with the request_kwargs of the method it wraps; its bound keywords take precedence as parameters of the request. Dropping them silently would turn a requester-pays request into an unexplained 403. - part_ranges() returns [] for size 0 only, so a negative size still raises; the multipart writer no longer special-cases size 0. - The delete-marker translation is tested next to the other mappings in test_s3_errors.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if self._request_kwargs: | ||
| request = {**self.operation_params(func.__name__, self._request_kwargs), **request} | ||
| target, bound = func, set(request) | ||
| while isinstance(target, functools.partial): |
There was a problem hiding this comment.
Self-review round one (behavior, failure paths, data boundaries, simplicity, test quality), base 766d79d0af49f9bcd14ccf4f08d9751c976e523e, head 1de9476c6391fb957e19d4e8b68f8a2a06cb2ec1. Result: FINDINGS, repaired in 0b9aa8f6. Sources: my own pass, /code-review high, /simplify.
Finding 1 (s3_core.py:646 at 1de9476). call() resolved the operation only by __name__, so call(functools.partial(client.get_object, Bucket="rp"), Key="k") on a core with request_kwargs={"RequestPayer": "requester"} was sent without RequestPayer. A requester-pays bucket answers 403, which gives no hint that the configured parameters were skipped. The first version's test test_call_callable_without_name pinned that behavior.
Repair (decided with the maintainer: unwrap partials). Nested partials are unwrapped to the client method they wrap, and that operation's request_kwargs are added. Keywords bound in a partial are treated as request parameters and take precedence. Without that, a call-time request_kwargs entry would override the partial's binding, because partial merges call keywords last. A callable whose __name__ is not a client method is still sent without them, as before (for example send in test_call_failure_frees_request; generate_presigned_url, which is not an API operation). test_call_partial replaces the old test. It fails both without the unwrapping and without the bound-keyword precedence.
| cover the whole object in order. | ||
| cover the whole object in order; empty for an empty object. | ||
| """ | ||
| if size == 0: |
There was a problem hiding this comment.
Round one, finding 2 (s3_core.py:1125 at 1de9476). if not starts: return [] also returned [] for a negative size, which used to raise ValueError. A caller with a bad size would then plan zero parts instead of failing. Repair: the guard is if size == 0 at the top, which states the documented empty-object case directly. S3MultipartWriter.copy_ranges() validates size >= 0 and dropped its own size == 0 early return, since part_ranges(0, ...) now yields no ranges (test_s3_writer.py still passes).
There was a problem hiding this comment.
Correction from round two: the writer method is S3MultipartWriter.iter_copy_parts(), not copy_ranges(). The PR body is corrected too.
| "MethodNotAllowed", | ||
| ], | ||
| ) | ||
| def test_os_error_delete_marker(self, code): |
There was a problem hiding this comment.
Round one, finding 3 (test placement). The delete-marker rule lives in S3ClientError._translate, but it was tested only through an S3Core/Stubber test. That test adds no evidence beyond test_call_translates_errors, which already covers the call() translation path. Repair: the test moved here, next to the 405 → EPERM fallback (header absent) and the PreconditionFailed rule. _client_error gained headers=, and the test is parametrized over the HEAD code 405 and the GET code MethodNotAllowed. Mutation check: both cases fail without the branch.
| if self._code == "PreconditionFailed" and self._condition == "If-None-Match": | ||
| # A conditional write (IfNoneMatch="*") found an existing object. | ||
| return FileExistsError(self._message) | ||
| if self._http_status_code == 405 and self._delete_marker: |
There was a problem hiding this comment.
Round one, deferred and rejected:
- CopyObject of a delete-marker source version (
s3_core.py:1186): reportedly answered with 400InvalidRequest, not 405, so it maps toOSError(EINVAL). This was not verified live (the versioning fixtures are opt-in and wait 15 minutes). It is not a regression of this PR, andcp_filelooks up the source with HeadObject first, which now raisesFileNotFoundError. Out of scope for Translate delete-marker versions to FileNotFoundError and fix two S3Core edge cases #1338. - Public
delete_markerproperty: no caller needs to tell a delete marker apart fromNoSuchVersion. Not added. - Header case and
"True": botocore stores the response headers lowercased (botocore/parsers.py,lowercase_dict), and S3 sendstrue. A hand-builtClientErrorin another case is outside the contract. Not changed. - Reading the header only for 405 (efficiency): one dict lookup per error, not measurable. It also matches the existing
_conditionfield. Not changed.
| | S3 error | Python exception | | ||
| | --- | --- | | ||
| | `404` / `NoSuchKey` / `NoSuchBucket` | `FileNotFoundError` | | ||
| | `405` of a version that is a delete marker (`x-amz-delete-marker: true`) | `FileNotFoundError` | |
There was a problem hiding this comment.
Self-review round two (claims, existing callers, AWS operator, documentation reader, evidence), base 766d79d0af49f9bcd14ccf4f08d9751c976e523e, head 0b9aa8f6fe41f7b1f523e0f4953bbc88a8c848fb. Result: FINDINGS (one wording error), corrected.
Claims checked against evidence:
- S3 answers HeadObject/GetObject of a delete-marker version with 405 and
x-amz-delete-marker: true. AWS "Working with delete markers" (userguide/DeleteMarker.html) states both. The HeadObject and GetObject API pages state the 405. exists()returns False, andinfo()/cat_file()/open()/S3Core.head_object()/get_object()raiseFileNotFoundError. I ran each offline against this branch with a stubbed client:exists -> False, the others raisedFileNotFoundError.info()re-raises for a versioned path without a listing fallback (s3.py:428-436).AioS3FileSystem._existsdelegates to the sync method.- Any other 405 still maps to EPERM.
test_os_error_by_http_status_code[405]is unchanged and passes. - The
S3Coredocstring anddocs/filesystem.mdalready promisedFileNotFoundErrorfor a missing version. Yes:s3_core.py:497-503anddocs/filesystem.md:400-402. No other doc mentions 405 or a delete-marker error, and therequest_kwargsprose (docs/filesystem.md:482-486, presigned URLs) is unaffected. - A negative size still raises in
part_ranges().part_ranges(-1, 5 MiB)raisesValueError(verified).plan_multipart_copyreturns beforepart_rangesfor sizes that fit one CopyObject, so size 0 never reaches it there. - A nested partial is resolved.
partial(partial(c.head_object, Bucket=...))was sent withRequestPayer(Stubberexpected_params, verified). - botocore lowercases header names in
ResponseMetadata.HTTPHeaders.botocore/parsers.py:282(lowercase_dict(headers)) at the resolved botocore. - Each new test fails with its behavior reverted. Mutation runs: delete-marker branch removed gives 2 failures; partial unwrapping removed gives 1; the bound-keyword filter removed gives 1; the original
part_rangesgives 1.
AWS operator: 405 is not in the retry policy's exceptions, and translation happens after retry_api_call, so attempts and elapsed time are unchanged. No request is added.
Existing callers: the only exception change is PermissionError → FileNotFoundError for delete-marker versions, recorded under Compatibility. A partial with request_kwargs used to raise AttributeError, so no working caller changes.
Correction: the PR body and the round-one record named S3MultipartWriter.copy_ranges(). The method is iter_copy_parts(); both are fixed.
Deferred: the first commit's message describes the superseded drop-silently behavior for partials. The second commit's message records the replacement, so the history is not rewritten.
Limits: no live S3 run has created a real delete marker. The versioning fixtures are opt-in with a 15-minute wait. CI runs the S3 integration suites once the PR is Ready.
| response_metadata = error.response.get("ResponseMetadata", {}) | ||
| status_code = response_metadata.get("HTTPStatusCode") | ||
| self._http_status_code: int | None = int(status_code) if status_code is not None else None | ||
| headers = response_metadata.get("HTTPHeaders", {}) |
There was a problem hiding this comment.
Independent review (relayed result), static only.
- Reviewer: Codex CLI 0.164.0, model
gpt-6-astra, reasoning effortmax,codex exec --sandbox read-only. It did not author the change. Session01a16cb5-656e-7032-9fbc-e9c3111a38ce. - Scope: base
766d79d0af49f9bcd14ccf4f08d9751c976e523e, head0b9aa8f6fe41f7b1f523e0f4953bbc88a8c848fb, run on a detached snapshot of the head. The prompt gave the intended behavior without the PR description, commit messages, or prior findings. The reviewer was not allowed to edit, build, run tests, or use GitHub or the network. - Surfaces covered: the full diff;
s3_errors.pydelete-marker detection, other 405 responses, and exception propagation;S3Core.call,operation_params,part_ranges,plan_multipart_copy, andgenerate_presigned_url; writer copy-range planning; the sync and aioexists/info/ls/expand_pathcallers; the changed docs and docstrings; both test files and the existing writer coverage; botocore 1.43.102 source, matchinguv.lock.
Result: CLEAN. The reviewer reports that botocore supplies lowercased headers under ResponseMetadata.HTTPHeaders. A 405 with the delete-marker header maps to FileNotFoundError, and ordinary 405 responses keep EPERM. Empty range planning returns [], while negative sizes still raise ValueError. Nested partials keep the precedence explicit request keywords → partial-bound keywords → configured defaults. By static inspection, the added tests check observable behavior and would fail if their fixes were removed. There are no documentation or test-quality findings.
The snapshot and the PR worktree were unchanged after the review (clean status, same head).
WHAT
Fixes the three
S3Coreand error-translation defects of #1338 (defects 19 to 21 of the #1327 audit).405error response with thex-amz-delete-marker: trueheader, which S3 returns for HeadObject or GetObject of a version that is a delete marker (Working with delete markers), is now translated toFileNotFoundErrorinstead ofPermissionError.fs.exists("s3://bucket/key?versionId=<delete marker>")therefore returnsFalse, andinfo(),cat_file(),open()andS3Core.head_object()/get_object()raiseFileNotFoundError, as theS3Coredocstring anddocs/filesystem.mdalready promised for a missing version. The delete-marker case is recognized by the header; any other405still maps toEPERM(pinned bytest_s3_errors.py). The error-translation table indocs/filesystem.mdlists the new row.S3Core.part_ranges(0, block_size)returns[]instead of raisingValueErrorfromzip(strict=True). The documented return value now says an empty object has no ranges. A negative size still raises.S3MultipartWriter.iter_copy_parts()no longer special-cases size 0.S3Core.call()withrequest_kwargsresolves afunctools.partial. A partial has no__name__, which raisedAttributeError. It is now identified by the client method it wraps (through nested partials), so it gets that operation'srequest_kwargs; its bound keywords take precedence, as parameters of the request do. A callable whose__name__is not that of a client method is still sent withoutrequest_kwargs, as before. The docstring documents both.Compatibility
A caller that caught
PermissionErrorfor a delete-marker version now receivesFileNotFoundError. Both areOSErrorsubclasses.WHY
Closes #1338. Part of #1327.
TEST
Tested at 0b9aa8f.
just lint: passed.just docs lint: 0 errors.uv run --env-file .env pytest -n 0 tests/pyathena/filesystem/test_s3_core.py tests/pyathena/filesystem/test_s3_errors.py tests/pyathena/filesystem/test_s3_writer.py: 242 passed (offline, botocoreStubber).test_s3_errors.py::test_os_error_delete_marker(the HEAD code405and the GET codeMethodNotAllowed, both with status 405 and the header),test_s3_core.py::test_call_partial(the wrapped method'srequest_kwargsare added, and a keyword bound in the partial wins), and an empty-object case oftest_part_ranges. Each fails with the corresponding change reverted;test_call_partialalso fails without the bound-keyword precedence.core_repros.py,exists_delete_marker.py) against this branch:part_ranges(0): [], both delete-marker lookups raiseFileNotFoundError,partial ok,exists: False.test_s3.py/test_s3_async.py; other sessions were running filesystem tests against AWS at the same time. They run in CI once the PR is Ready. No live test creates a real delete marker: the versioning fixtures are opt-in and wait 15 minutes. The header name and value follow the AWS documentation linked above, and botocore exposes response headers lowercased inResponseMetadata.HTTPHeaders(botocore/parsers.py).🤖 Generated with Claude Code