Move the multipart upload requests into S3Core (step 3.2 of #1063) - #1069
Conversation
Add S3Core.create_multipart_upload(), upload_part(), upload_part_copy(), complete_multipart_upload() and abort_multipart_upload(), one request each, and part_ranges(), the range split of a multipart copy and of an append. The fields that the arguments set still take precedence over inherited parameters of the same name. complete_multipart_upload() takes the typed parts, and abort_multipart_upload() raises; logging and swallowing a failed abort stays in the filesystem's callers. The part limits become S3Core attributes, and the filesystems no longer have the MULTIPART_UPLOAD_* attributes (maintainer decision; breaking change for 4.0.0). S3File, pipe_file(), clear_multipart_uploads() and the sync and aio multipart copies call fs.core.*; the requests sent are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…docs A write replaces the object at the key, so the core rejects a path with a version ID instead of ignoring it, as the filesystem already does. Also document that abort_multipart_upload() raises FileNotFoundError for an upload that does not exist (measured), that part_ranges() sends no request, and build the path of an S3File multipart upload once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if not path.key: | ||
| raise ValueError(f"The path has no key: {path.uri}.") | ||
| if path.version_id: | ||
| raise ValueError(f"Cannot write to a version: {path.uri}.") |
There was a problem hiding this comment.
Self-review round one (implementation behavior). Base 911492c3e63ae6ba8c8892be2543fd1b51a75447, head 3825953dfc51b842229f1eb563931176f3797880; findings made on f730dd1, repaired in 3825953. Sources: my own pass, /code-review (high) and /simplify (reuse, simplification, efficiency, altitude).
Covered: the five core primitives, part_ranges() and the limits; S3File write/append/discard; pipe_file(), _check_multipart_upload_size(), clear_multipart_uploads(), _finish_multipart_upload(), _abort_multipart_upload(); the sync and aio multipart copies (cancellation logic unchanged); the test rewiring and docs. The kwargs that reach the primitives all pass operation_params() (CamelCase API names), so the new argument names cannot collide with them, unlike the rm_file case in #1064. The request precedence {**params, **request} and S3Core.call's request_kwargs merge are as before.
FINDING (repaired): the public create_multipart_upload() ignored a version ID on the destination, so core.create_multipart_upload(S3Path('b', 'k', 'v1')) + parts + complete overwrote the current object at k, where the filesystem raises ValueError (#958). It now raises ValueError; no adapter passes a version there. The other primitives work on the upload ID, so the entry point is the one place to check.
FINDING (docs, repaired): abort_multipart_upload() now documents FileNotFoundError for a missing upload (measured live: NoSuchUpload → FileNotFoundError), and docs/filesystem.md says that part_ranges() sends no request.
FINDING (simplification, repaired): the append path built two identical S3Paths per copied range; _initiate_upload() builds it once.
| "Key": path.key, | ||
| "UploadId": upload_id, | ||
| "MultipartUpload": { | ||
| "Parts": [{"ETag": p.etag, "PartNumber": p.part_number} for p in parts] |
There was a problem hiding this comment.
Self-review round one — pre-existing defects found by /code-review, verified, and split into issues per the maintainer's choice (this PR stays behavior-preserving):
CompleteMultipartUploadsends onlyETag/PartNumber, so an upload created withChecksumAlgorithmcannot complete. Measured live with SHA256 and CRC32: S3 answersInvalidRequest("The complete request must include the checksum for each part"). Same request as master's_finish_multipart_upload/aio copy.clear_multipart_uploads()raisesFileNotFoundErrorwhen a listed upload is completed or aborted before its abort (abort of a missing upload measured live →FileNotFoundError); unchanged by this PR.
Reasoned deferrals: the hard-coded "5 MiB"/"5 GiB" in the block-size messages only disagree when tests override the limits; a subclass overriding the removed MULTIPART_UPLOAD_* attributes is silently ignored, which follows from the removal decision (release note); CopySourceRange formatting duplicates S3File._format_ranges in one line because the core cannot import s3.py; storing one S3Path on S3File, S3Path parameters for _finish_multipart_upload/_abort_multipart_upload, merging the two append branches and the duplicated block-size validation belong to sub-steps 3 and 4.
|
|
||
| `create_multipart_upload()`, `upload_part()`, `upload_part_copy()`, | ||
| `complete_multipart_upload()` and `abort_multipart_upload()` send the requests of a | ||
| multipart upload. `part_ranges()` sends no request: it splits an object into the byte |
There was a problem hiding this comment.
Self-review round two (claims, callers, AWS operation, docs). Base 911492c3e63ae6ba8c8892be2543fd1b51a75447, head 3825953dfc51b842229f1eb563931176f3797880. Result: CLEAN after PR-text corrections.
Claims checked:
- "No request change on the wire except one edge case": every primitive keeps
{**params, **request}andS3Core.call'srequest_kwargsmerge;discard()andclear_multipart_uploads()send the same AbortMultipartUpload fields. The append'sCopySourcechanged from"bucket/key"to a dict:botocore.handlers.handle_copy_source_param(botocore 1.43.102) encodes both identically for keys with spaces,+,%, non-ASCII and?; only a key likea?versionId=v1?bdiffers, where the string form was misread as a version. Stated in the PR. - Docs:
abort_multipart_uploadof a missing upload →FileNotFoundError(measured live);part_ranges()sends no request; the cache note now covers all core requests. The other filesystem docs (5 MiB, 5 GiB, 10,000 parts) still hold. - Existing callers: the removed
MULTIPART_UPLOAD_*attributes raiseAttributeErrorwhen read and are ignored when a subclass sets them — added to the PR's breaking note. The removed private helpers have no remaining references inpyathena/,tests/,docs/orbenchmarks/. - AWS operation: retries and error translation still go through
S3Core.call; the aio copy keepsto_thread+wait/shieldand its cleanup unchanged. Pre-existing Multipart uploads with ChecksumAlgorithm cannot complete, and clear_multipart_uploads() fails on finished uploads #1070 (checksum completion, clear race) measured live and left unchanged. - Evidence: offline failure set identical to master at 3825953 (664 passed / 119 AWS-dependent failed); the live runs were on f730dd1 (PR body corrected to say so). Full AWS suite pending CI on Ready.
The test of a failed abort replaced only fs._call, but the abort now goes through fs.core.call, so the abort succeeded and the test passed without the failure. Set the failure on the shared mock and assert the abort and its log. Also document that a missing multipart upload raises FileNotFoundError, and that range_=None leaves a CopySourceRange of the params in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| key=key2, | ||
| **create_kwargs, | ||
| ) | ||
| multipart_upload = self.core.create_multipart_upload(destination, **create_kwargs) |
There was a problem hiding this comment.
Independent review (relayed) — static, read-only review of git diff 911492c3e63ae6ba8c8892be2543fd1b51a75447..3825953dfc51b842229f1eb563931176f3797880 on a clean detached snapshot; the reviewer ran no builds or tests. The prompt stated the intended behavior but not the PR number, description, commit messages or prior findings.
Reviewer: Codex CLI (codex exec -s read-only, model gpt-6-astra, session 01a10545-fc2c-7690-ac30-16508c3b7e2d). Covered: core multipart APIs, limits, ranges, precedence, response types, error translation; sync/aio scheduling, cancellation, completion/abort, cache invalidation, S3File write/append/commit/discard; pipe_file, put_file, clear_multipart_uploads, tests, docs. Result: FINDINGS (3).
- P2 — Multipart copy changes keyword-collision exceptions (here and s3_async.py:640). For a source larger than 5 GiB,
fs.cp_file(src, dst, key="other")previously raisedTypeError(collision with the helper's explicit argument); it now reaches botocore and raisesParamValidationError. Conversely an extrapathkeyword now collides with the core argument.- P2 — The abort-failure test no longer injects an abort failure (test_s3.py:3311): it replaces only
fs._call, but the abort now usesfs.core.call, so the test passes without exercising the failed-abort handling.- P3 —
range_=Nonedoes not guarantee copying the whole source (s3_core.py:671): an inheritedCopySourceRangein the params stays; document that None supplies no range override.
Author verification:
- Rejected, reasoned:
_get_multipart_copy_kwargsforwards unknown names for botocore to reject; on both base and head such a call fails client-side before CreateMultipartUpload is sent, so nothing is written. Only the exception class differs for meaningless lowercase kwargs, unlike Move the delete requests into S3Core (step 3.1 of #1063) #1064'srm_file, where the change deleted a different version. Fable reached the same conclusion ("informational, same class of garbage input"). - CONFIRMED (coverage regression introduced here): only this test replaced
_callalone (the two others at :2965/:3057 setcore.callfirst). Repaired in 6e2fb2e. - CONFIRMED (docstring): repaired in 6e2fb2e.
There was a problem hiding this comment.
Repairs: (2) 6e2fb2e sets the abort failure on the mock shared by fs._call and fs.core.call and asserts the abort request and its log; checked by mutation (the test fails when _abort_multipart_upload stops swallowing). (3) 6e2fb2e + e8f2853: range_=None sends no range of its own; a CopySourceRange from params or the core's request_kwargs still applies. Lint passed; offline failure set identical to master (664 passed / 119 AWS-dependent failed). Both self-review perspectives re-applied (test now load-bearing; docstring claims traced to {**params, **request} and S3Core.call's merge).
Independent follow-ups (relayed): Codex CLI read-only, gpt-6-astra, session 01a10550-09b7-7b41-853e-f506bc92e507, on 3825953d..6e2fb2e0: one P3 — the docstring still omitted request_kwargs as a source of CopySourceRange → fixed in e8f2853; Codex on 6e2fb2e0..e8f28532: CLEAN ("accurately describes range_=None").
|
|
||
| Raises: | ||
| ValueError: If the path has no key. | ||
| FileNotFoundError: If the upload does not exist, for example |
There was a problem hiding this comment.
Independent review (relayed) — static, read-only review of git diff 911492c3e63ae6ba8c8892be2543fd1b51a75447..3825953dfc51b842229f1eb563931176f3797880 on a clean detached snapshot; the reviewer ran no builds or tests. The prompt stated the intended behavior but not the PR number, description, commit messages or prior findings.
Reviewer: Claude claude-fable-5-1 (Agent tool, model override fable). Covered: all new core methods vs the deleted helpers and inline aborts; S3Core.call; the sync/aio copies, _finish_multipart_upload, _abort_multipart_upload, clear_multipart_uploads, _check_multipart_upload_size, pipe_file, S3File init/append/chunk/commit/discard; botocore handle_copy_source_param for the append CopySource; the test rewiring and docs. Result: FINDINGS (1, low; no behavior regressions found).
Low (docs/docstring accuracy) —
docs/filesystem.md:282-285 and theS3Coreclass docstring said an operation raisesFileNotFoundErrorfor a missing bucket or a missing object/version it reads;upload_part,upload_part_copy,complete_multipart_uploadandabort_multipart_uploadon a stale upload ID also raise it (NoSuchUpload). Suggest adding "or a missing multipart upload".
Also noted as behavior-preserving: request precedence, CopySourceRange formatting, source versions, the append CopySource encoding (and, incidentally, a directly constructed S3File(fs, "s3://bucket/key", "ab") used to send the scheme in CopySource; the dict form works), part numbering, abort semantics, the aio cast, typing.
Author verification: CONFIRMED (NoSuchUpload → FileNotFoundError measured live for the abort); repaired in 6e2fb2e.
There was a problem hiding this comment.
Repaired in 6e2fb2e: the S3Core class docstring and docs/filesystem.md now say that a missing bucket or multipart upload raises FileNotFoundError.
Independent follow-up (relayed): claude-fable-5-1 (Agent tool, model override fable) on 3825953d..6e2fb2e0: CLEAN — the test injects through _abort_multipart_upload → S3Core.abort_multipart_upload → S3Core.call and would fail if the abort error stopped being swallowed; the docstrings match NoSuchUpload → FileNotFoundError and the range construction. Non-actionable nits recorded: a short rewrapped docs line, and upload_part/upload_part_copy/complete_multipart_upload not listing FileNotFoundError individually (the class docstring covers it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
Sub-step 2 of #1063 (step 3 of #1053): the multipart upload requests move into
S3Core.Core (
pyathena/filesystem/s3_core.py, public):create_multipart_upload(path, **params),upload_part(path, upload_id, part_number, body, **params),upload_part_copy(path, upload_id, part_number, source, range_=None, **params),complete_multipart_upload(path, upload_id, parts, **params)andabort_multipart_upload(path, upload_id, **params), one request each. They return the existingS3MultipartUpload,S3MultipartUploadPartandS3CompleteMultipartUpload.upload_part_copy()takes the source as anS3Path(its version ID, if any, goes intoCopySource) and the range as(start, end)with an exclusive end.complete_multipart_upload()takes the typed parts instead of{"ETag", "PartNumber"}dicts.abort_multipart_upload()raises. Logging and swallowing a failed abort stays in the filesystem's_abort_multipart_upload(), andS3File.discard()still lets the error propagate.create_multipart_upload()raisesValueErrorfor a destination with a version ID instead of ignoring it, as the filesystem already does for a versioned write (Accept version_id in S3FileSystem.open() and cat_file() #958); no filesystem path passes one.part_ranges(size, block_size)is the former_get_copy_ranges(), used by the multipart copies and by an append.MULTIPART_UPLOAD_MIN_PART_SIZE,MULTIPART_UPLOAD_MAX_PART_SIZEandMULTIPART_UPLOAD_MAX_PARTSareS3Coreattributes.Adapters:
S3File(write, append, discard),pipe_file(),_check_multipart_upload_size(),clear_multipart_uploads(),_finish_multipart_upload(),_abort_multipart_upload()and the sync and aio multipart copies callfs.core.*and read the limits from the core. The aio cancellation handling (thefailedflag, the semaphore, the cleanup tasks, abort unless the completion succeeded) is unchanged._create_multipart_upload,_upload_part,_upload_part_copy,_complete_multipart_upload,_get_copy_ranges.S3FileSystem.MULTIPART_UPLOAD_MIN_PART_SIZE,MULTIPART_UPLOAD_MAX_PART_SIZEandMULTIPART_UPLOAD_MAX_PARTSare removed; theS3Coreattributes are the only limits. Code that reads them from a filesystem getsAttributeError, and a subclass that sets them no longer changes the limits.part_ranges()indocs/filesystem.md; the note that requests sent throughfs.coredo not invalidate the filesystem's cache now covers every operation.Request changes: none on the wire, with one edge case. An append used to send
CopySource="bucket/key"as a string and now sends{"Bucket", "Key"}. botocore encodes both the same way, including spaces,+,%, non-ASCII characters and?(checked withbotocore.handlers.handle_copy_source_param). The exception is a key such asa?versionId=v1?b, whichS3Pathtreats as an unversioned, writable key: botocore read the string form as versionv1?bofa, while the dict form copies the key itself.Known pre-existing defects, unchanged here and tracked in #1070: a multipart upload created with
ChecksumAlgorithmcannot complete (no part checksums inCompleteMultipartUpload), andclear_multipart_uploads()fails on an upload that is completed or aborted after the listing.WHY
#1063 / #1053: S3 requests and S3 rules belong in the typed core; scheduling, cache invalidation and fsspec semantics stay in the adapters. This is the second of the four PRs (delete #1064 → multipart primitives → copy → pairing).
TEST
Tested commit: e8f2853 (lint; offline suite on 6e2fb2e, which differs only by a docstring); the live runs were on f730dd1, which differs only by the versioned-destination check, docstrings, building the
S3Fileupload path once, and one offline testjust lint: passed.just docs lint: passed.pytest --noconftest -n 8 tests/pyathena/filesystemwith dummy credentials: 664 passed, 119 failed. The failure set is identical toorigin/master911492c (652 passed, 119 failed); all 119 need AWS.tests/pyathena/filesystem/test_s3_core.pyfor each primitive: the request shape, the precedence of the arguments over**params, the versionedCopySourceandCopySourceRange, a whole-source part copy, the typed parts of the completion, an abort that raises (NoSuchUpload→FileNotFoundError, also measured live), keyless paths and a versioned destination. The_get_copy_rangestests moved there aspart_rangestests.test_finish_multipart_upload_abort_failure_does_not_mask_the_original_errornow injects the abort failure through the core and asserts the abort and its log (checked by mutation: it fails if_abort_multipart_uploadstops swallowing the error).test_s3.py/test_s3_async.pynow mockfs.core.*and set the limits onfs.core; their assertions changed only in call shape (S3Pathinstead ofbucket=/key=, typed parts, the client method instead of the method name in_call).uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/test_s3.py tests/pyathena/filesystem/test_s3_async.py -k "multipart or append or cp_file or copy or write or pipe or put_file or mv": 225 passed, 1 failed on a stalefs._create_multipart_upload(bucket=, key=)call left intest_list_and_clear_multipart_uploads; fixed before the commit and re-run (-k test_list_and_clear_multipart_uploads: 4 passed).🤖 Generated with Claude Code