Skip to content

Translate delete-marker versions to FileNotFoundError and fix two S3Core edge cases (GH-1338) - #1345

Merged
laughingman7743 merged 2 commits into
masterfrom
fix/1338-delete-marker-core-edges
Oct 26, 2026
Merged

laughingman7743 merged 2 commits into
masterfrom
fix/1338-delete-marker-core-edges

Conversation

@laughingman7743

@laughingman7743 laughingman7743 commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

WHAT

Fixes the three S3Core and error-translation defects of #1338 (defects 19 to 21 of the #1327 audit).

  1. Delete-marker versions are not found. A 405 error response with the x-amz-delete-marker: true header, which S3 returns for HeadObject or GetObject of a version that is a delete marker (Working with delete markers), is now translated to FileNotFoundError instead of PermissionError. fs.exists("s3://bucket/key?versionId=<delete marker>") therefore returns False, and info(), cat_file(), open() and S3Core.head_object()/get_object() raise FileNotFoundError, as the S3Core docstring and docs/filesystem.md already promised for a missing version. The delete-marker case is recognized by the header; any other 405 still maps to EPERM (pinned by test_s3_errors.py). The error-translation table in docs/filesystem.md lists the new row.
  2. S3Core.part_ranges(0, block_size) returns [] instead of raising ValueError from zip(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.
  3. S3Core.call() with request_kwargs resolves a functools.partial. A partial has no __name__, which raised AttributeError. It is now identified by the client method it wraps (through nested partials), so it gets that operation's request_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 without request_kwargs, as before. The docstring documents both.

Compatibility

A caller that caught PermissionError for a delete-marker version now receives FileNotFoundError. Both are OSError subclasses.

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, botocore Stubber).
  • New regression tests: test_s3_errors.py::test_os_error_delete_marker (the HEAD code 405 and the GET code MethodNotAllowed, both with status 405 and the header), test_s3_core.py::test_call_partial (the wrapped method's request_kwargs are added, and a keyword bound in the partial wins), and an empty-object case of test_part_ranges. Each fails with the corresponding change reverted; test_call_partial also fails without the bound-keyword precedence.
  • The audit's offline reproductions (core_repros.py, exists_delete_marker.py) against this branch: part_ranges(0): [], both delete-marker lookups raise FileNotFoundError, partial ok, exists: False.
  • Not run locally: the live S3 integration tests in 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 in ResponseMetadata.HTTPHeaders (botocore/parsers.py).

🤖 Generated with Claude Code

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

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 (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:

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.

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).

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.

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

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.

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:

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.

Round one, deferred and rejected:

  • CopyObject of a delete-marker source version (s3_core.py:1186): reportedly answered with 400 InvalidRequest, not 405, so it maps to OSError(EINVAL). This was not verified live (the versioning fixtures are opt-in and wait 15 minutes). It is not a regression of this PR, and cp_file looks up the source with HeadObject first, which now raises FileNotFoundError. Out of scope for Translate delete-marker versions to FileNotFoundError and fix two S3Core edge cases #1338.
  • Public delete_marker property: no caller needs to tell a delete marker apart from NoSuchVersion. Not added.
  • Header case and "True": botocore stores the response headers lowercased (botocore/parsers.py, lowercase_dict), and S3 sends true. A hand-built ClientError in 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 _condition field. Not changed.

Comment thread docs/filesystem.md
| S3 error | Python exception |
| --- | --- |
| `404` / `NoSuchKey` / `NoSuchBucket` | `FileNotFoundError` |
| `405` of a version that is a delete marker (`x-amz-delete-marker: true`) | `FileNotFoundError` |

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, existing callers, AWS operator, documentation reader, evidence), base 766d79d0af49f9bcd14ccf4f08d9751c976e523e, head 0b9aa8f6fe41f7b1f523e0f4953bbc88a8c848fb. Result: FINDINGS (one wording error), corrected.

Claims checked against evidence:

  1. 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.
  2. exists() returns False, and info()/cat_file()/open()/S3Core.head_object()/get_object() raise FileNotFoundError. I ran each offline against this branch with a stubbed client: exists -> False, the others raised FileNotFoundError. info() re-raises for a versioned path without a listing fallback (s3.py:428-436). AioS3FileSystem._exists delegates to the sync method.
  3. Any other 405 still maps to EPERM. test_os_error_by_http_status_code[405] is unchanged and passes.
  4. The S3Core docstring and docs/filesystem.md already promised FileNotFoundError for a missing version. Yes: s3_core.py:497-503 and docs/filesystem.md:400-402. No other doc mentions 405 or a delete-marker error, and the request_kwargs prose (docs/filesystem.md:482-486, presigned URLs) is unaffected.
  5. A negative size still raises in part_ranges(). part_ranges(-1, 5 MiB) raises ValueError (verified). plan_multipart_copy returns before part_ranges for sizes that fit one CopyObject, so size 0 never reaches it there.
  6. A nested partial is resolved. partial(partial(c.head_object, Bucket=...)) was sent with RequestPayer (Stubber expected_params, verified).
  7. botocore lowercases header names in ResponseMetadata.HTTPHeaders. botocore/parsers.py:282 (lowercase_dict(headers)) at the resolved botocore.
  8. 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_ranges gives 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", {})

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 result), static only.

  • Reviewer: Codex CLI 0.164.0, model gpt-6-astra, reasoning effort max, codex exec --sandbox read-only. It did not author the change. Session 01a16cb5-656e-7032-9fbc-e9c3111a38ce.
  • Scope: base 766d79d0af49f9bcd14ccf4f08d9751c976e523e, head 0b9aa8f6fe41f7b1f523e0f4953bbc88a8c848fb, 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.py delete-marker detection, other 405 responses, and exception propagation; S3Core.call, operation_params, part_ranges, plan_multipart_copy, and generate_presigned_url; writer copy-range planning; the sync and aio exists/info/ls/expand_path callers; the changed docs and docstrings; both test files and the existing writer coverage; botocore 1.43.102 source, matching uv.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).

@laughingman7743
laughingman7743 marked this pull request as ready for review October 24, 2026 07:05
@laughingman7743
laughingman7743 merged commit 1a6a0d6 into master Oct 26, 2026
14 checks passed
@laughingman7743
laughingman7743 deleted the fix/1338-delete-marker-core-edges branch October 26, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Translate delete-marker versions to FileNotFoundError and fix two S3Core edge cases

1 participant