Allow ? in keys and address versions in listings, files and copies - #1055
Conversation
Only a version ID query at the end of a path is a version now, so keys containing "?" can be read, listed and deleted (S3Path.PATTERN and the new S3Path.split_version_id(), also used by invalidate_cache()). A path with a version names that version: expand_path() no longer globs its "?", and copy(), get() and mv() (sync and aio) pair it with a destination named after its key instead of the version-qualified name. ls(versions=True) names each version "bucket/key?versionId=<id>", except the "null" version, as s3fs does, and a version-aware S3File carries the version pinned at open time in its path, so that metadata(), getxattr() and url() describe that version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| paths1 = [p for p in paths1 if not (trailing_sep(p) or self.isdir(p))] | ||
| if not paths1: | ||
| return [], [] | ||
| glob = isinstance(path1, str) and has_magic(path1) and not _has_version_id(path1) |
There was a problem hiding this comment.
Self-review round one (implementation behavior): CLEAN.
Base 1dc7a6b (#1052 head), head 460c4cb.
Covered:
S3Path.PATTERN/split_version_id(): a trailing query only, an empty ID, an ID containing?, and strings that are not S3 paths.expand_path(): fsspec's own recursion re-enters it with lists andassume_literalinkwargs, which are forwarded. TheFileNotFoundErroris the same as fsspec's when nothing matches.recursivemakes one lookup per version._copy_paths()(here) against fsspec 2026.9.0copy()/get()/_copy()/_get(): the non-recursive directory filter,exists(fsspec'shas_magicexcept for a version),other_pathson names without the version,make_path_posixand the localisdirforget(). Sources without a version still take fsspec's path, includingcheck_contained.mv()through_move_paths(); the aio mirrors;rm()'s versioned paths (unchanged, no lookup).invalidate_cache()for?keys and for versions.- The
S3Fileversion pin: only whenversion_awareand S3 reports a version, appended to the path given to the base class, as Accept version_id in S3FileSystem.open() and cat_file() #958's explicit version is. ls(versions=True)names, includingnull.
Tests: each new offline test fails on the base. The base raises ValueError for ? keys and FileNotFoundError for version paths, metadata() sends no VersionId, and ls() returns plain names. The live test exercises a real ? key and ?versionId=null.
No findings.
There was a problem hiding this comment.
Repair after the independent review, both self-review perspectives: CLEAN.
The repair is 460c4cb..43ab8f9. The merge-base with master is still 1dc7a6b, now that #1052 is merged as 77892e8.
- Implementation:
PATTERNwithre.DOTALL/\Zand the bare/first: re-checked against every earlier parse case (bucket,bucket/,bucket//, trailing-slash keys,?keys,?versionId=empty, two queries, every spelling)._check_contained(): the root itself is allowed, which is the single-file case.- aio
_expand_path(): fsspec's recursion re-enters it withassume_literal, which is forwarded._isfile()swallows lookup errors as fsspec's does. _split_version_paths()is shared by sync and aio.isfile()replacesexists()for version paths.get()pairs any non-listlpathaftermake_path_posix(), which acceptsPathLike.
- Claims: the PR body no longer says that aio delegates to the sync expansion. It describes the containment check (
ValueErrorfora/.., replacing the earlier measuredIsADirectoryErrornote), theisfilerule and theprefix=behavior, and records the evidence on 43ab8f9:just lintpassed, the offline failures are the same 118 AWS-dependent tests plus the new live test, and the live run passed 713 tests.
| if self.version_id: | ||
| # Carried in the path as an explicit version is, so that | ||
| # the methods of the file use it too. | ||
| path = f"{path}?versionId={self.version_id}" |
There was a problem hiding this comment.
Self-review round two (claims and callers): FINDINGS, repaired.
Same base and head as round one.
Finding: the PR body said a destination of a versioned source "cannot leave lpath". For a key whose last segment is .. (a/..?versionId=v1), the destination is lpath/... I measured it offline: get() raises IsADirectoryError and writes nothing, so the conclusion holds but the wording was wrong. Repaired by rewording the PR body to the measured behavior. No code change.
Claims checked:
- The pinned path follows Accept version_id in S3FileSystem.open() and cat_file() #958:
git log -Sfinds the explicit-version carry in bbf3759, which is part of Accept version_id in S3FileSystem.open() and cat_file() #958. - On the base,
get()andcopy()/mv()/expand_path()of a version path raiseFileNotFoundError(measured offline on 1dc7a6b). On the branch they copy toout,d/bandd/b. ls(versions=True)naming matches s3fs 2026.9.0_fill_info()(read in the s3fs wheel).- The release note about
s3://bucket/key?foo=baris covered by the updatedtest_parse_path_invalid(sync and aio). - Existing callers:
copy/getkeep fsspec's signatures and theiron_error,callbackandbatch_sizehandling, because the paired lists are passed tosuper(). Only the version-awareS3File.pathand the listing names change visibly, and both are in the release notes. - Evidence:
just lintpassed; the offline failures are the same 118 AWS-dependent tests as on the base; the livepytest -n 4 tests/pyathena/filesystem/run passed 698 tests on this head. Versions other thannullwere not run live (unversioned CI bucket).
- S3Path.PATTERN matches keys with newlines again, also at the end, and reads "bucket/?versionId=..." as a version of the bucket path, as before. - get() checks that the destinations it names for version paths lie under lpath, as fsspec does for the destinations that it names, and accepts a PathLike destination; _has_version_id() accepts PathLike sources. - AioS3FileSystem._expand_path() keeps fsspec's async expansion for the paths without a version, whose glob lists only the keys under the stem. - A version path is kept by a recursive expansion only if it is a file, so a key prefix of the same name does not count as the version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| rpath, lpath = self._copy_paths( | ||
| rpath, root, recursive=recursive, maxdepth=maxdepth, isdir=LocalFileSystem().isdir | ||
| ) | ||
| _check_contained(root, lpath) |
There was a problem hiding this comment.
Independent review (relayed): FINDINGS (1 P1, 9 P2).
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, codex exec -s read-only (session 01a1044e-66d8-7770-9b75-e897c4e723fe). This was a static review that read the local fsspec 2026.9.0 sources, on a detached snapshot of head 460c4cb, base 1dc7a6b; the snapshot was unchanged afterward. The prompt had the diff and the intended behavior, without the PR text or the self-review records. Repairs are in 43ab8f9. Per-finding verdicts are in this thread and the threads below.
Finding 1 (P1), anchored here: the generated pairs entered fsspec's explicit-list branch of get(), which skips check_contained(). On Windows, a mixed list such as [r"bucket/..\victim.txt", "bucket/ok?versionId=v1"] could write above lpath. Confirmed. Repaired: get() and aio _get() check every destination they name against lpath (_check_contained(), the same rule as fsspec's). test_get_version_outside_destination covers a/..?versionId=v1 and a mixed list on POSIX.
Finding 4 (P2): a version-free Path source raised TypeError in copy()/get() because _has_version_id() iterated it. Confirmed; repaired (os.PathLike and os.fspath), see test_has_version_id.
Finding 5 (P2): a Path destination of aio _get() (and sync get()) skipped the pairing, so the file went to out.bin/key?versionId=v1. Confirmed; repaired by pairing any non-list lpath after make_path_posix(), see aio test_get_version_path_destination and the Path cases of test_get_version.
There was a problem hiding this comment.
Independent follow-up 1 (relayed): FINDINGS (3 P2).
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, codex exec -s read-only (session 01a10464-6390-7661-bcad-4c6dbcb1e432). Static review of the repair 460c4cb..43ab8f9 on the detached snapshot, which was unchanged afterward. It confirmed that original findings 1-4 and the scenario of 9 are resolved, that 5's Path case is fixed, and that the rejections of 7, 8 and 10 are sound.
New findings and dispositions; the repairs are in b3b7d2f:
isfile()/_isfile()swallow every exception, so aPermissionErroron one version path silently dropped it from a recursive expansion. Confirmed. Repaired by usingexists()/_exists()again. Since a16f063,info()no longer takes a key prefix for a missing version, so the prefix case stays excluded and other lookup errors are raised. Test:test_expand_path_recursive_version_lookup_error.- An aio
_copy()/_get()source list that mixes a version path with a glob is paired by the synchronous_copy_paths(), so its glob lists the directory of the stem rather thanprefix=. Not changed: that is howS3FileSystem.copy()/get()expand the same list, and it matters only for such a mixed list under a policy that allows listing only the stem prefix. Unversioned aio expansions keep fsspec's asyncprefix=(finding 6). The PR body states this. - A tuple
lpathraisedTypeErrorin_check_contained(). Confirmed. Repaired:get()/_get()pair only astrorPathLikelpath, and a sequence is left to fsspec as before. Test: a tuple case oftest_get_version.
Both perspectives on the repair: CLEAN. The new tests fail on 43ab8f9, where the expansion returns only a and abspath(tuple) fails. just lint passed, the offline failures are the same AWS-dependent set, and the live pytest -n 4 tests/pyathena/filesystem/ run passed 717 tests on b3b7d2f.
There was a problem hiding this comment.
Independent follow-up 2 (relayed): FINDINGS (1).
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, codex exec -s read-only (session 01a10470-7e65-7f13-a7c0-ce3ba33b1561). Static review of the patch 43ab8f9..b3b7d2f on the detached snapshot, which was unchanged afterward.
Coverage reported: the info() change and its callers (exists, isdir, _copy_file/cp_file, open/S3File, cat_file, expand_path, mv/_move_paths, get_file), the sync and async get destination pairing, and the updated test expectations. It confirmed that follow-up 1's findings 1 and 3 are repaired.
Finding: with exists(), a recursive expansion keeps a version-qualified bucket path (s3://bucket/?versionId=x) when the bucket exists, and a copy of it then fails with ValueError("Cannot copy buckets.").
Not changed. A non-recursive expansion keeps the same path as given and reaches the same explicit ValueError, so the two modes agree. A version on a bucket path names nothing, and exists() on a bucket path checks only the bucket, as before. Nothing is copied or overwritten. The review found no other actionable regression.
There was a problem hiding this comment.
Maintainer-requested change: fsspec>=2026.9.0 and S3Path.has_version_id(), in 491e567. Self-review from both perspectives: CLEAN.
- Implementation:
S3Path.has_version_id(path)takes a singlestr/PathLike. The "path or list" convention stays in the fsspec-facingcopy/get/_copy/_get, which applyany()to their sources.- The removed module helper
_check_contained()is replaced byfsspec.utils.check_contained(). I checked the published fsspec wheels: that function is absent in 2025.12.0 through 2026.7.0 (2026.5.0 and 2026.8.0 could not be downloaded) and present in 2026.9.0, hence the newfsspec>=2026.9.0. ItsValueErrornames the escaping path, andtest_get_version_outside_destinationstill matchesoutside. - The
kwargs.pop("onerror")incp_file()/_cp_file()only served fsspec < 2026.6.0'smv(). PyAthena's ownmv()/_mv()never pass it, so the pop is removed.
- Claims: the PR body gains a release note (fsspec>=2026.9.0, which had no lower bound) and describes the change.
uv lockchanges only the recorded specifier; the locked fsspec was already 2026.9.0. Evidence on 491e567:just lintpassed, the offline failures are the same AWS-dependent set, and the livepytest -n 4 tests/pyathena/filesystem/run passed 717 tests.
There was a problem hiding this comment.
Independent follow-up 3 (relayed): CLEAN.
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, codex exec -s read-only (session 01a1048a-c3e3-7141-b30d-8ef993703ed1). This was a static review of the patch b3b7d2f..491e567 on the detached snapshot, which was unchanged afterward.
Coverage reported:
- Sync and aio
copy/get/mv, with their sync wrappers, versioned and unversioned sources, destination mapping, and kwargs and error propagation. No supported move path generates the removedonerror. S3Path.has_version_id()with the callers'any()preserves the previous predicate.- fsspec 2026.9.0
check_contained(): correct(root, paths)arguments, and equivalent normalization, root equality, separator boundaries andValueError; only the message wording differs. - The dependency requirement, the lock metadata, the tests and the docs: no remaining dependence on older fsspec.
No files were changed, nothing was built or tested, and there was no network access.
| r"($|\?version(Id|ID|id|_id)=(?P<version_id>.+)$)" | ||
| r"(^s3://|^s3a://|^)(?P<bucket>[a-zA-Z0-9.\-_]+)(/|/(?P<key>.+?))?" | ||
| rf"(\Z|{VERSION_QUERY.pattern})", | ||
| re.DOTALL, |
There was a problem hiding this comment.
Independent review (relayed), findings 2 and 3 (P2): confirmed and repaired.
- Finding 2:
.+?excluded newlines.bucket/a\nbraisedValueError, andbucket/a\nparsed as keyabecause$matched before the final newline. Both keys parsed intact on the base. The pattern now usesre.DOTALLand\Z. - Finding 3:
bucket/?versionId=v1parsed as the key?versionId=v1, whereas the base read it as a version of the bucket path. A bare/is now tried before a key.
Both are covered by new TestS3Path.test_parse cases (a newline inside, at the end, and before a version).
| """ | ||
| if maxdepth is not None and maxdepth < 1: | ||
| raise ValueError("maxdepth must be at least 1") | ||
| versions, others = self._sync_fs._split_version_paths(path) |
There was a problem hiding this comment.
Independent review (relayed), finding 6 (P2): confirmed and repaired. Delegating aio _expand_path() to the sync expand_path() lost fsspec's async glob prefix= (the stem before the first wildcard), so bucket/reports/2026-*.csv listed reports/ instead of reports/2026-.
The aio override now splits off the version paths with the shared _split_version_paths() and passes the others to fsspec's async _expand_path(). test_expand_path_glob_lists_stem_prefix asserts prefix="2026-".
| if maxdepth is not None and maxdepth < 1: | ||
| raise ValueError("maxdepth must be at least 1") | ||
| versions, others = self._split_version_paths(path) | ||
| out = {p for p in versions if not recursive or self.isfile(p)} |
There was a problem hiding this comment.
Independent review (relayed), finding 9 (P2, pre-existing cause): with only dir/child present, a recursive expansion of bucket/dir?versionId=missing kept the path, because exists() → info() falls back to the prefix lookup.
Repaired for the expansion: a version path is kept only if isfile(). test_expand_path_recursive_missing_version now covers this, and it fails on the base.
The underlying info() behavior predates this PR and is not changed here: a version path whose object is missing is reported as a directory when the key is also a prefix (s3.py prefix fallback in info(), outside the diff).
Finding 10 (P2, pre-existing): expand_path("bucket/key?versionId=v/") drops the trailing / of the version ID. Not changed: fsspec's _strip_protocol() does this for every operation on the base, and the review notes the same.
There was a problem hiding this comment.
Follow-up at the maintainer's request: the pre-existing info() cause is fixed in this PR as well, in a16f063. Self-review from both perspectives: CLEAN.
- Implementation: when HeadObject does not find an explicitly requested version,
info()now raisesFileNotFoundErrorinstead of falling back to the ListObjectsV2 key-prefix check. This covers both the path query and theversion_id=argument;version_idis the merged value. The callers follow:exists()returns False.isdir()returns False._copy_file()raisesFileNotFoundErrorinstead of skipping the path as a directory, whichcopy(on_error=...)handles.open()raisedFileNotFoundErrorfor a directory already, so it is unchanged.cat_file()'s negative-offset lookup raises as before.
Version paths that are found, and paths without a version, take the same requests as before.
- Claims: the PR body adds the release note (
FileNotFoundError/False, and one ListObjectsV2 fewer for a missing version).test_info_version_spellings_share_cachenow expects 3 requests instead of 5, because the two prefix checks are gone.test_info_missing_version_is_not_a_prefixfails on the previous head, whereinfo()returned a directory. Evidence on a16f063:just lintpassed, the offline failures are the same AWS-dependent set, and the livepytest -n 4 tests/pyathena/filesystem/passed 715 tests.
| and (trailing_sep(path2) or (isdir or self.isdir)(path2)) | ||
| ) | ||
| ) | ||
| names = [S3Path.split_version_id(p)[0] for p in paths1] |
There was a problem hiding this comment.
Independent review (relayed), findings 7 and 8 (P2): not changed, with these reasons.
- Finding 7: copying
["bucket/a/?versionId=v1", "bucket/b/?versionId=v2"]intobucket/out/names both destinationsbucket/out/, because fsspec's flattened basename of a trailing-slash key is empty. Flattening a list already maps sources with the same basename onto one destination in fsspec'scopy()(for example["bucket/a/x", "bucket/b/x"]→bucket/out/xtwice), andmv()rejects such collisions in_move_paths(). Versions of directory-marker keys follow the same rule. - Finding 8: copying
bucket/a?versionId=x?versionId=v1(keya?versionId=x) into a directory names the destinationbucket/out/a?versionId=x, which_copy_file()rejects withValueError: Cannot copy to a versioned file. A key that itself ends with a version query cannot be named as a destination path, as the PR body and docs state, and the error is explicit; nothing is overwritten.
A path with a version names an object, so info() raises FileNotFoundError when HeadObject does not find that version, instead of reporting a key prefix of the same name as a directory. exists() is False for it, and the ListObjectsV2 request is no longer sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s to fsspec - A recursive expansion checks a version path with exists() again, which raises lookup errors other than FileNotFoundError; info() no longer takes a key prefix for a missing version, so the prefix case still excludes it. isfile() hid those errors and dropped the path. - get() and _get() pair version paths only for a str or PathLike lpath, so a tuple of destinations is mapped by fsspec as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- S3Path.has_version_id() tells whether a path string ends with a version ID query; copy()/get() and their aio forms apply it to each source. - get() and _get() check the destinations that they name with fsspec.utils.check_contained(), which fsspec 2026.9.0 added, instead of a copy of it. The fsspec requirement is raised accordingly (4.0.0). - The "onerror" keyword that fsspec < 2026.6.0 leaked from mv() into cp_file is no longer removed, as the new requirement excludes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
Stacked on #1052 (
S3Path); the base branch isrefactor/1049-s3-path.?.S3Path.PATTERNtreats only a version ID query at the end of a path (?versionId=,?versionID=,?versionid=,?version_id=, with a non-empty ID without?) as a version, and any other?as part of the key. The newS3Path.split_version_id()applies the same rule to any string, such as the names fsspec builds.invalidate_cache()now uses it in place of splitting at the first?.info(),exists(),open(),cat(),rm(recursive=True)and the other methods accept keys such asdir/what?.txt, whichls()/find()already returned.S3FileSystem.expand_path()andAioS3FileSystem._expand_path()return a path with a version as given. Withrecursive, the path is kept only if the version exists (a key prefix of the same name does not count, see 5; lookup errors other than a missing object are raised), and nothing below it is expanded.copy()andget()(sync, plus aio_copy()/_get()) pair sources that have a version with destinations named after the key without the version, through the new_copy_paths()._move_paths()(mv()) uses the same pairing. Other sources still go through fsspec's owncopy()/get().version_aware=True,S3Filecarries the version it pins at open time in its path (bucket/key?versionId=<id>), as an explicitversion_idalready does since Accept version_id in S3FileSystem.open() and cat_file() #958.metadata(),getxattr()andurl()of the file therefore describe and sign that version, and an unpickled file reads it again.ls(versions=True)names each versionbucket/key?versionId=<id>in both the strings and thenameof thedetail=Trueentries, except for thenullversion, which keeps the plain key. This is the same as s3fs's_fill_info(). The names can be passed back toopen()/info()/copy().info()no longer falls back to the key-prefix lookup when HeadObject does not find an explicitly requested version. A version names an object, so the path does not exist.get()/_get()check the destinations they name with fsspec's owncheck_contained(), which fsspec 2026.9.0 added, and theonerrorworkaround for fsspec < 2026.6.0 incp_file()/_cp_file()is removed.S3Path.has_version_id()(public) tells whether a path string ends with a version ID query.docs/filesystem.mddescribes the version rule, the pinned path, the listing names and versioned copies.Release notes (4.0.0):
?can be used. A path such ass3://bucket/key?foo=bar, which used to raiseValueError: Invalid S3 path format, is now the keykey?foo=bar. A key that itself ends with a version ID query, such askey?versionId=x, is still read as a version.ls(versions=True)returns version-qualified names (bucket/key?versionId=<id>, except fornull) instead of the plain key once per version.version_aware=True,S3File.pathof a file opened for reading includes?versionId=<pinned id>when S3 reports a version, andmetadata(),getxattr()andurl()use that version instead of the latest.expand_path(),copy(),mv()andget()accept a single path with a version ID, which used to fail withFileNotFoundErrorafter a ListObjectsV2 request.info()of a path with a version that does not exist raisesFileNotFoundError(andexists()returns False) even when its key is also a key prefix, which used to be reported as a directory. The ListObjectsV2 request that checked for the prefix is no longer sent for a missing version.When
get()pairs the paths itself (a source with a version and astrorPathLikelpath; a sequence of destinations is left to fsspec), it checks that every destination it names lies underlpath, with fsspec'scheck_contained(), as fsspec'sget()does for the destinations it names. A key such asa/..therefore raisesValueError. Sources without a version keep fsspec's pairing unchanged.rm()keeps deleting versioned paths as given, without a lookup.AioS3FileSystem._expand_path()passes the paths without a version to fsspec's async_expand_path(), so their globs keep listing only the keys under the stem before the first wildcard (prefix=). A source list of aio_copy()/_get()that contains a version path is paired by the synchronous_copy_paths(), whose globs list the directory of the stem, asS3FileSystemdoes.WHY
Closes #979, including the case in its comment (version paths globbed by
expand_path()/copy()/mv()/get()).Maintainer decisions on 2026-10-04: s3fs naming for the listed versions, the pinned version carried in
S3File.pathrather than newversion_idarguments onmetadata()/getxattr()/sign(), and the copy/get/mv pairing fixed in this PR.TEST
Tested commit 491e567, on master after #1052 was merged (77892e8).
just lint: passed.just docs lint: 0 errors.tests/pyathena/filesystem/:test_question_mark_keys: listing,info(),exists()and recursiverm()of?keys.test_invalidate_cache_question_mark_key.test_expand_path_versionandtest_expand_path_recursive_missing_version.test_copy_version:copy()/mv()to a key, to a directory, from a list, and with a?key.test_get_version: to a file, a directory and from a list.test_open_version_aware_pins_version_in_path: the requests ofmetadata()/getxattr()/url()carryVersionId.test_copy_version,test_get_versionandtest_get_version_path_destination.test_info_missing_version_is_not_a_prefix(path query andversion_id=);test_info_version_spellings_share_cachenow expects no ListObjectsV2 after a missing version.TestS3Path.test_has_version_id(strings andPaths) replaces the test of the removed module helper.test_expand_path_recursive_version_lookup_error(aPermissionErroris raised, not taken for a missing version) and a tuple-destination case oftest_get_version.uv lockonly records the new specifier; the locked fsspec stays 2026.9.0, which CI uses. CI has no lowest-version job.test_get_version_outside_destination,test_has_version_id(Pathsources), thePathcases oftest_get_version, aiotest_expand_path_glob_lists_stem_prefix, a strongertest_expand_path_recursive_missing_version(a key prefix of the same name is not a version), and parser cases for newlines andbucket/?versionId=.test_ls_versionscases,test_parse_path_invalid(sync and aio), andS3Pathparse andsplit_version_idcases.expand_path()madecopy()/mv()raiseCannot copy to a versioned file(the version ended up in the destination), and madeget()write the local filef.txt/b?versionId=v1. The final pairing copies toout,d/b,d/b.tests/pyathena/filesystem/(--noconftest, dummy credentials): the same 118 AWS-dependent tests fail as on the base branch, and all other tests pass.uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/→ 717 passed on 491e567 (and on b3b7d2f; 715 on a16f063, 713 on 43ab8f9, and 698 on 460c4cb before the review repairs). This includes the newtest_question_mark_keys_and_null_version, which writes, lists, reads and deletes a realwhat?.txtkey, and copies and downloads its?versionId=nullpath on the unversioned CI bucket.null, because the CI bucket is unversioned and the CI role lackss3:DeleteObjectVersion. Those cases are covered offline.🤖 Generated with Claude Code