Count S3FileSystem.find() maxdepth levels like fsspec - #956
Conversation
find(path, maxdepth=n) listed n + 1 levels: _find() listed the current level and recursed while maxdepth > 0. fsspec's find() and walk() count the level directly under the path as 1 and reject maxdepth < 1, and glob(), expand_path(), rm(), and du() pass maxdepth on with that meaning. rm(path, recursive=True, maxdepth=1) therefore also deleted the objects one level below the path. Recurse only while more than one level remains, and raise ValueError for maxdepth < 1. With maxdepth, also return the path itself when it is an object, as find() already does without maxdepth. Closes #933 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| # Recursively explore subdirectory if depth allows | ||
| if maxdepth > 0: | ||
| if maxdepth > 1: |
There was a problem hiding this comment.
Self-review round one (implementation behavior): FINDINGS (repaired)
Base e0e85da09435257a18c8b8832b3e10ba7840b697, head 8f29bebed998ae97316a24f28a15fd6f6a1a3a89; full diff (pyathena/filesystem/s3.py, tests/pyathena/filesystem/test_s3.py, tests/pyathena/filesystem/test_s3_async.py).
Covered:
- Level counting against fsspec 2026.9.0
walk()/find():maxdepth=1lists only the current level, and each recursion passesmaxdepth - 1 >= 1, so the new< 1check never fires inside the recursion. - Callers that pass
maxdepththroughfind(): fsspecglob()(its computed depth is at least 1 whenevermaxdepth >= 1),expand_path()(used byS3FileSystem.rm()and fsspeccopy()/get()/put()),du(), and the async_glob()/_expand_path()/_du()throughAioS3FileSystem._find(). AValueErrorfrom the sync_find()propagates throughasyncio.to_thread. - Object-path fallback: it runs only when the current level lists nothing and the path has a key, and it returns only a file. A directory result from
info()therefore cannot make the loop recurse into the same path. A subdirectory that becomes empty between listings costs one extra HEAD request and returns[]. Withprefix, it behaves the same as the unlimited branch (see the updated docstring). - Tests: the new unit tests fail on master and pass here;
test_find_refresh_bypasses_cached_listingsstill reaches the subdirectory listing withmaxdepth=2.
Findings:
- The new unit test compared
find()results in listing order, which comes from_ls_dirs(common prefixes before contents) and is not a contract. Repaired in c1292e0: the multi-entry assertions compare sorted lists. - Pre-existing, out of scope:
AioS3FileSystem._rm()(pyathena/filesystem/s3_async.py:178) callsexpand_path(p, recursive=recursive)withoutmaxdepth, somaxdepthis ignored and_rm(path, recursive=True, maxdepth=1)deletes everything under the path. This PR does not change it; to be filed separately. - PR text: the
rm()consequence applies to the syncS3FileSystem.rm()only (see finding 2). The PR body will be corrected in round two's claim audit.
There was a problem hiding this comment.
Round one on the repair (54660a4): CLEAN. git range-diff e0e85da0..c1292e03 e0e85da0..54660a49 shows the two reviewed commits unchanged plus one commit. The maxdepth branch is again master's listing loop, with only the < 1 check and the > 1 recursion guard changed. key is still used by the unlimited branch. Removing the tests drops only the fallback coverage; test_find_maxdepth_counts_levels_like_fsspec and the live test_find_maxdepth still fail against the old numbering.
| Args: | ||
| path: S3 path to search under (e.g., "s3://bucket/prefix"). | ||
| maxdepth: Maximum depth to recurse (None for unlimited). | ||
| maxdepth: Maximum number of levels to descend, at least 1 |
There was a problem hiding this comment.
Self-review round two (claims, callers, operations): FINDINGS (PR text corrected)
Base e0e85da09435257a18c8b8832b3e10ba7840b697, head c1292e030417ed8a289f9df333ccb676aa3a668c; full pass over the PR body, commit messages, and changed docstrings.
Claims checked:
- Old numbering (
0gave the current level,ngaven + 1levels, negative values behaved like0): reproduced with a mocked three-level listing on master;maxdepth > 0guarded the recursion. - fsspec 2026.9.0 documents
maxdepthas "the maximum number of levels to descend" (find), andwalk(),glob(), andexpand_path()raiseValueError("maxdepth must be at least 1"): confirmed infsspec/spec.py. ValueErrorbefore any request: the unit test asserts_callwas not called.- Object path returned with
maxdepth, as fsspecfind()does (if not out and self.isfile(path)) and as the unlimited branch already did: confirmed. Theprefixdocstring no longer limits this to calls withoutmaxdepth. - Introduced in v3.15.0 (681e749), and before that
maxdepthwas ignored (# TODO: Support maxdepth and withdirs, full listing): confirmed withgit tag --containsand the parent revision. maxdepthreachesfind()fromS3FileSystem.rm()(viaexpand_path), fsspecglob()/du()/copy()/get(), and the async_glob()/_du()/_expand_path()/_copy()/_get(): confirmed. No other PyAthena module passesmaxdepthor callsfind()with it.
Corrections to the PR body:
put()was listed among the callers, but fsspecput()expands the local path withLocalFileSystem.expand_path(), soS3FileSystem.find()is not involved. Removed.- The
rm()consequence holds for the syncS3FileSystem.rm()only;AioS3FileSystem._rm()ignoresmaxdepth(pre-existing,s3_async.py:178). Qualified, and recorded under "Not changed". - "Tested at" now separates the live run (8f29beb) from the assertion-only repair (c1292e0, offline tests rerun).
Caller/operator effects: callers that passed maxdepth=0 now get ValueError, and callers relying on the old numbering need maxdepth + 1; both are in the release note. S3 requests drop by one listing level per call. The object-path fallback adds at most one info() (HEAD, then a 1-key listing on 404) when the top-level listing is empty, the same cost as the unlimited branch.
Evidence limits: live S3 coverage is the targeted -n 1 run (28 tests, sync and async) at 8f29beb; the full filesystem and other suites run in CI after Ready.
There was a problem hiding this comment.
Round two on the repair (54660a4): FINDINGS (PR text corrected). The PR body no longer claims the object-path behavior and lists #962-#966 under "Not changed". It states the 4.0.0-only target (milestone set on #933 and this PR), and "Tested at" now names 54660a4 with the 27-test live run. Request-count claim: back to master's single listing for a no-match prefixed maxdepth search (mocked measurement). Commit 8f29beb's message still mentions the fallback; 54660a4's message records its removal, and the history is not rewritten.
| if not current_items and key: | ||
| # The path itself may be an object, as without maxdepth. | ||
| try: | ||
| info = self.info(path, refresh=refresh) |
There was a problem hiding this comment.
Independent review (relayed): FINDINGS
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, sandbox read-only, session 01a10061-dcb1-73f2-8ccb-4a2717704c21. Static review only (no builds, tests, or GitHub access). Base e0e85da09435257a18c8b8832b3e10ba7840b697, head c1292e030417ed8a289f9df333ccb676aa3a668c, detached snapshot; the prompt contained the literal diff, the source snapshot, and the resolved fsspec 2026.9.0 source, with no PR text, commit messages, or prior findings. The snapshot and the PR worktree were unchanged afterwards.
Covered: both find branches, _ls_dirs/info caching, prefix/refresh, object/empty/missing paths, fsspec find/walk, the callers (glob, expand_path, du, copy, get, rm, async delegation), and the mocked/AWS tests. The reviewer found the depth correction sound and noted that the tests fail against the old behavior.
Findings and author verification:
- P1, pre-existing:
AioS3FileSystem._rm()(s3_async.py:191) never passesmaxdepthtoexpand_path, so_rm("bucket/dir", recursive=True, maxdepth=1)deletes everything under the path. Verified; same as round one. Not changed here; to be filed. - P2, introduced: this object-path fallback. fsspec's async
_glob()passes the filename stem asprefix, so a no-match async glob such asdir/no-match*reaches this branch with an empty listing and now callsinfo(). If HEAD is denied while LIST is allowed, the call raisesPermissionErrorinstead of returning[]. Verified: with a mocked listing,_find("bucket/dir/", maxdepth=1, prefix="no-match")issues 3 requests (LIST, HEAD, LIST) instead of master's 1; the permission case needs a HEAD 403 for a missing key while LIST is allowed. Repair: the fallback is removed from this PR, and thefind(object, maxdepth=n)inconsistency is to be tracked in its own issue. - P2, pre-existing:
withdirs=Trueomits the search root (s3.py:766,s3.py:790), which fsspec includes. Verified, in both branches. To be filed. - P2, pre-existing: a
prefixcontaining/bypasses the depth limit (s3.py:751), e.g.find("bucket/dir", maxdepth=1, prefix="sub/deep/")returns a third-level file. Verified; fsspec'sglob()passes only a slash-free stem, so this affects direct callers. To be filed. - P2, pre-existing:
info()cannot use listings cached by_ls_dirs(s3.py:1964):_ls_from_cachereads string keys only, while listings are stored under(path, delimiter). Verified. To be filed.
There was a problem hiding this comment.
Repair (54660a4) for finding 2: the object-path fallback is removed from the maxdepth branch, and the prefix docstring is restored to the master wording ("Without maxdepth, ..."). test_find_maxdepth_returns_object_path and the object-path assertions in the sync/async test_find_maxdepth are removed with it. The find(object_path, maxdepth=n) inconsistency is now #966.
Pre-existing findings filed: 1 → #962, 3 → #963, 4 → #964, 5 → #965.
Validation at 54660a4: just lint passed; the offline find tests pass; the mocked no-match prefixed _find("bucket/dir/", maxdepth=1, prefix="no-match") is back to 1 request (3 at c1292e0); uv run --env-file .env pytest -n 1 tests/pyathena/filesystem/test_s3.py tests/pyathena/filesystem/test_s3_async.py -k "find or glob or rm or walk or du or expand": 27 passed.
An independent follow-up on the repair is pending.
There was a problem hiding this comment.
Independent follow-up on the repair (relayed): CLEAN
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, sandbox read-only, session 01a10076-12a4-7471-9e5f-fab69886d38e. Static review only. Scope: git range-diff e0e85da0..c1292e03 e0e85da0..54660a49 (the two reviewed commits unchanged, plus 54660a4), the added commit's diff, and the full base..head diff, on a detached snapshot at 54660a49c40f8e90ba8d93272cfe18ed2f068641; the known pre-existing issues (#962-#966) were excluded.
Covered: S3FileSystem._find/find, AioS3FileSystem._find, fsspec 2026.9.0 sync/async glob/expand_path/du, the remaining tests, and the changed docstrings. Reported: removing the fallback restores the base behavior for empty bounded listings; literal glob and recursive expand_path keep their existence checks; the remaining unit test and the sync/async test_find_maxdepth still detect the original off-by-one, and zero-depth rejection stays covered; the restored prefix wording accurately limits the object fallback to unlimited-depth searches.
The snapshot and the PR worktree were unchanged afterwards (HEAD 54660a49, clean).
fsspec's async _glob() passes the filename stem as prefix, so a glob without matches reached the fallback with an empty listing and paid a HeadObject and another ListObjectsV2 request for nothing. Keep this change to the maxdepth level counting; find(object_path, maxdepth=n) returning [] is tracked separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
S3FileSystem.find()(andAioS3FileSystem._find(), which calls it) now countsmaxdepththe way fsspec does:maxdepth0or lessValueError("maxdepth must be at least 1")1nn + 1levelsnlevels_find()now recurses only while more than one level remains, and rejectsmaxdepth < 1before listing anything.The
find()docstring describes the level counting and theValueError, and_find()has a docstring.Release note (4.0.0, behavior change)
S3FileSystem.find(maxdepth=n)listsnlevels instead ofn + 1, andmaxdepth=0raisesValueError. Callers that passedmaxdepthrelying on the old numbering needmaxdepth + 1to keep their results.S3FileSystem.rm(),glob(),du(),expand_path(),copy(), andget()(and theirAioS3FileSystemcounterparts except_rm()) passmaxdepththroughfind(), soS3FileSystem.rm(path, recursive=True, maxdepth=1)no longer deletes the objects one level below the path.This is a 4.0.0 change only; 3.x keeps the old numbering.
WHY
Fixes #933.
fsspec documents
maxdepthas "the maximum number of levels to descend", and itswalk(),glob(), andexpand_path()rejectmaxdepth < 1.fsspec's
glob(),expand_path(), anddu()callfind()with that meaning, so the extra level reached the callers too.On master,
S3FileSystem.rm(path, recursive=True, maxdepth=1)expanded to the objects one level below the path and deleted them, andglob("dir/**", maxdepth=1)returned them.For example, with the keys
d/a.csv,d/sub/c.csv, andd/sub/deep/e.csv,rm("s3://bucket/d", recursive=True, maxdepth=1)sent DeleteObjects ford,d/a.csv,d/sub,d/sub/c.csv, andd/sub/deep, deletingd/sub/c.csvoutside the requested depth; with this change it sendsd,d/a.csv, andd/sub.This dates from the
maxdepthsupport added in v3.15.0 (681e749); before that,maxdepthwas ignored.TEST
Tested at 54660a4.
just lint: passed.TestS3FileSystem::test_find_maxdepth_counts_levels_like_fsspec(new, no AWS): a three-level mocked listing; checks thatmaxdepth=0raises before any request, and the results ofmaxdepth=1(with and withoutwithdirs),2, and3. On master it fails; with this change it passes.TestS3FileSystem::test_find_refresh_bypasses_cached_listings: usesmaxdepth=2to keep reaching the subdirectory listing it checks.test_find_maxdepth(real S3, sync and async): updated to the fsspec numbering, plusmaxdepth=0raising.uv run --env-file .env pytest -n 1 tests/pyathena/filesystem/test_s3.py tests/pyathena/filesystem/test_s3_async.py -k "find or glob or rm or walk or du or expand": 27 passed.expand_path(path, recursive=True, maxdepth=1)(used byrm()) andglob("dir/**", maxdepth=1)no longer include the second level, and a no-match prefixed_find(maxdepth=1)(as fsspec's async_glob()sends) still issues one request.Not run locally: the rest of the filesystem tests and the other suites; CI runs them once the PR is Ready.
Not changed (pre-existing, filed separately):
AioS3FileSystem._rm()ignoresmaxdepth(#962),withdirs=Trueomits the root (#963), aprefixcontaining/bypasses the depth limit (#964),info()does not use cached listings (#965), andfind(object_path, maxdepth=n)returns[](#966).🤖 Generated with Claude Code