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
1 change: 1 addition & 0 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ operations raise natural errors instead of botocore's `ClientError`:
| 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.

| `403` / `AccessDenied` | `PermissionError` |
| `BucketAlreadyExists` / `BucketAlreadyOwnedByYou` | `FileExistsError` |
| `PreconditionFailed` of an `If-None-Match` condition | `FileExistsError` |
Expand Down
18 changes: 15 additions & 3 deletions pyathena/filesystem/s3_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import contextlib
import functools
import logging
import math
import traceback
Expand Down Expand Up @@ -620,7 +621,11 @@ def call(self, method: str | Callable[..., Any], **request) -> dict[str, Any]:
"""Send a request with the retry policy and translate its errors.

``request_kwargs`` that the operation accepts are added; a parameter
of the request takes precedence.
of the request takes precedence. The operation is identified by the
name of the method; a ``functools.partial`` is identified by the
method it wraps, and its bound keywords are parameters of the request.
A callable whose ``__name__`` is not that of a client method is sent
without them.

Args:
method: The name of the client method, or the method itself.
Expand All @@ -641,7 +646,12 @@ def call(self, method: str | Callable[..., Any], **request) -> dict[str, Any]:
"""
func = getattr(self._client, method) if isinstance(method, str) else method
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.

bound.update(target.keywords)
target = target.func
params = self.operation_params(getattr(target, "__name__", ""), self._request_kwargs)
request.update({k: v for k, v in params.items() if k not in bound})
try:
response = retry_api_call(func, config=self._retry_config, logger=_logger, **request)
except BaseException as e:
Expand Down Expand Up @@ -1115,8 +1125,10 @@ def part_ranges(self, size: int, block_size: int) -> list[tuple[int, int]]:

Returns:
The ``(start, end)`` byte ranges, with an exclusive end, that
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.

return []
block_size = max(block_size, math.ceil(size / self.MULTIPART_UPLOAD_MAX_PARTS))
starts = list(range(0, size, block_size))
if len(starts) > 1 and size - starts[-1] < self.MULTIPART_UPLOAD_MIN_PART_SIZE:
Expand Down
13 changes: 11 additions & 2 deletions pyathena/filesystem/s3_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ class S3ClientError:
by its HTTP status code; if neither is recognized, a generic ``OSError``
with the original error message is used. A ``PreconditionFailed`` error
of an ``If-None-Match`` condition, from a write that must not replace an
existing object, is mapped to ``FileExistsError``.
existing object, is mapped to ``FileExistsError``. A ``405`` error with
the ``x-amz-delete-marker: true`` header, from a request for a version
that is a delete marker, is mapped to ``FileNotFoundError``.

Example:
>>> try:
Expand Down Expand Up @@ -108,14 +110,21 @@ def __init__(self, error: botocore.exceptions.ClientError) -> None:
self._code: str = str(error_info.get("Code", ""))
self._message: str = str(error_info.get("Message", error))
self._condition: str = str(error_info.get("Condition", ""))
status_code = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
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).

self._delete_marker: bool = headers.get("x-amz-delete-marker") == "true"
self._os_error: OSError = self._translate()

def _translate(self) -> OSError:
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.

# HeadObject/GetObject of a version that is a delete marker.
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html
return FileNotFoundError(self._message)
exception = self._ERROR_CODE_TO_EXCEPTION.get(self._code)
if exception:
return exception(self._message)
Expand Down
2 changes: 0 additions & 2 deletions pyathena/filesystem/s3_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,6 @@ def iter_copy_parts(
raise ValueError("size must be nonnegative.")
if first_part_number < 1:
raise ValueError("part_number must be at least 1.")
if size == 0:
return
ranges = self._core.part_ranges(size, self._core.MULTIPART_UPLOAD_MAX_PART_SIZE)
for part_number, range_ in enumerate(ranges, first_part_number):
self._check_part_number(part_number)
Expand Down
28 changes: 28 additions & 0 deletions tests/pyathena/filesystem/test_s3_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# SPDX-License-Identifier: MIT

import copy
import functools
import gc
import io
import re
Expand Down Expand Up @@ -119,6 +120,31 @@ def test_call_translates_errors(self, status, code, error):
assert isinstance(e.value.__cause__, botocore.exceptions.ClientError)
assert e.value.__cause__.response["Error"]["Code"] == code

def test_call_partial(self):
# GH-1338: a functools.partial of a client method has no __name__; it
# is sent with the request_kwargs of the method it wraps, and its
# bound keywords take precedence as parameters of the request.
core, stubber = _make_core(
request_kwargs={"RequestPayer": "requester", "ExpectedBucketOwner": "111122223333"}
)
stubber.add_response(
"head_object",
{"ContentLength": 1},
expected_params={
"Bucket": "bucket",
"Key": "key",
"RequestPayer": "requester",
"ExpectedBucketOwner": "444455556666",
},
)
head_object = functools.partial(
core.client.head_object, Bucket="bucket", ExpectedBucketOwner="444455556666"
)
with stubber:
response = core.call(head_object, Key="key")
stubber.assert_no_pending_responses()
assert response["ContentLength"] == 1

@pytest.mark.parametrize("translated", [True, False])
def test_call_failure_frees_request(self, translated):
# GH-1186: the frames of the retries and of botocore below call() in
Expand Down Expand Up @@ -1082,6 +1108,8 @@ def test_upload_part_uses_creation_algorithm_and_identity(self):
@pytest.mark.parametrize(
("size", "block_size", "ranges"),
[
# GH-1338: an empty object has no ranges.
(0, 5 * 2**20, []),
# A single range.
(5 * 2**20, 5 * 2**20, [(0, 5 * 2**20)]),
# The size is an exact multiple of the block size.
Expand Down
23 changes: 22 additions & 1 deletion tests/pyathena/filesystem/test_s3_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@
from pyathena.filesystem.s3_errors import S3ClientError


def _client_error(code, message="error message", status_code=None, condition=None):
def _client_error(code, message="error message", status_code=None, condition=None, headers=None):
error_response = {"Error": {"Code": code, "Message": message}}
if condition is not None:
error_response["Error"]["Condition"] = condition
if status_code is not None:
error_response["ResponseMetadata"] = {"HTTPStatusCode": status_code}
if headers is not None:
# botocore lowercases the names of the response headers.
error_response.setdefault("ResponseMetadata", {})["HTTPHeaders"] = headers
return botocore.exceptions.ClientError(error_response, "TestOperation")


Expand Down Expand Up @@ -109,3 +112,21 @@ def test_os_error_precondition_failed(self, condition, expected):
if expected is OSError:
assert actual.errno == errno.EINVAL
assert "error message" in str(actual)

@pytest.mark.parametrize(
"code",
[
# HEAD responses have no body, so the code is the status code.
"405",
"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.

# GH-1338: HeadObject/GetObject of a version that is a delete marker
# is answered with 405 and the x-amz-delete-marker header, and the
# version does not exist as an object. Other 405 errors map to EPERM.
actual = S3ClientError(
_client_error(code, status_code=405, headers={"x-amz-delete-marker": "true"})
).os_error
assert type(actual) is FileNotFoundError
assert "error message" in str(actual)
Loading