Fix get_files(directory) on the S3 and local storage drivers - #220
Fix get_files(directory) on the S3 and local storage drivers#220tmgbedu wants to merge 2 commits into
Conversation
get_files(directory) was broken on both real drivers, in different ways, and the two disagreed about what a listing even means — so it could not be used from disk-agnostic code. S3 filtered nested keys by testing the whole key with `"/" not in key` instead of the part below the prefix, so any non-empty Prefix always returned [] while root-level keys leaked through. The remainder below the prefix is now what gets tested, a trailing slash on the directory is tolerated, and the directory placeholder object is skipped. Local listed bare names from os.listdir but read them back with self.get(name), which resolves against the disk root rather than root/<directory>. get() swallows FileNotFoundError, so callers silently received File objects with None content. The read is now joined to the directory, a missing directory returns [] instead of raising, and entries are sorted to match the order S3 returns. All three drivers now share one documented contract: a non-recursive listing of the files directly under the given directory, no directory entries. FakeDriver delegates to LocalDriver so it cannot drift. Adds get_files to the StorageManager/Storage passthroughs, and covers the S3 path with real integration tests against a local MinIO (skipped when no endpoint is reachable). Fixes #218 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyozUDQzC8en24yx1Je1ct
Review — PR #220 (fixes #218)The core prefix fix is right and I verified it against live MinIO. Two real defects in the local Numbers I observed myself on
🔴 Blocking1.
Reproduced on this branch: This is worse than the bug being fixed: silent It also breaks the "agree across drivers" claim in the commit message: What to do: don't read content during a listing. Populate 2.
🟡 Non-blocking notes3. The S3 key is recoverable but undiscoverable. #218's use case ("most recent file under this 4. 5. ✅ Verified as claimed
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Addresses the two blockers on PR #220. Listing read every file through get(), which opens in text mode, so one non-UTF-8 file raised UnicodeDecodeError and killed the whole listing — and issue #218's motivating example is an audio/ directory. It also broke the cross-driver contract the rest of this branch establishes: the same layout listed fine on S3, which never reads bodies, and exploded on local. Listings no longer carry a body at all; content is fetched on demand via get(join(directory, file.name())). get_path() calls make_file_path_if_not_exists(), so listing a missing nested directory silently os.makedirs'd its parent — a read-only operation mutating the disk. Path resolution is now split out into resolve_path(), which resolves against the disk root and touches nothing; get_path() keeps its create-on-demand behaviour for the write paths that want it, and get_files() uses resolve_path(). The earlier test missed this because a single-segment name's parent already exists; the new test uses a multi-segment path and asserts nothing was created. Documents on File what content means per driver, including that file.stream().key is how the full S3 key is recovered, and drops FakeDriver.get_files, which was a pure super() passthrough. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyozUDQzC8en24yx1Je1ct
|
Both blockers fixed in ddb17cb. Reproduced each first, wrote the tests, watched them fail against the previous head, then fixed. Blocker 1 — eager read crashes on binary filesReproduced exactly as described: Listings no longer carry a body at all — One correction to the justification, since I was asked to verify rather than take it. The claim was that local The conclusion still holds, for two other reasons: So this is safe, but not because content was always Blocker 2 — read-only listing mutated diskReproduced: Path resolution is now split. Tests, failing-firstThe six new assertions all failed against the previous head: The one that passed is Two existing tests asserted the old eager-content contract and were updated rather than deleted: Non-blocking items
VerificationMinIO re-verified after the change, unchanged: |
Follow-up — correction on my part, and one thing the zero-callers claim missesYou were right to push back, and I got a detail wrong. Re-checked everything by running the old Where I was wrongThe binary crash at the root is pre-existing, not introduced by this PR. I said the old code So "this PR turns silent None into a hard 500" was wrong as stated. Withdrawn. Where the old behaviour was worse than either of us saidIt was not That is data corruption, not just data loss, and it makes the core fix more clearly worth having. Where the zero-callers claim doesn't holdI grepped independently — framework, That caller demonstrably regresses: So the safety argument can't rest on zero callers — UnchangedBlocker 2 stands untouched by any of this — Everything in the "verified as claimed" section of my first comment also stands: prefix slice, the |
✅ Re-review of
|
Fixes #218
get_files(directory)was broken on both real drivers, in two different ways, and the drivers disagreed about what a listing means — making it unusable from disk-agnostic code.The two bugs
S3 — any non-empty prefix returned
[]. The nested-file filter tested the whole key with"/" not in keyinstead of the part below the prefix. Listingbackupsmatchedbackups/a.sql, which contains a slash, so every file was rejected; meanwhile root-level keys (root.txt) passed the test and leaked into prefixed listings. Now the prefix is stripped and the remainder is what gets tested. A trailing slash is tolerated (backups==backups/) and the directory placeholder object is skipped.Local — silent data loss.
os.listdiryields bare names, but the loop calledself.get(name), which resolves against the disk root rather thanroot/<directory>.get()swallowsFileNotFoundErrorand returnsNone, so callers gotFileobjects withNonecontent and no error. The read is now joined to the directory. A missing directory returns[]instead of raising, and entries are sorted so the order matches what S3 returns.Shared contract
All three drivers now document and implement the same thing: a non-recursive listing of the files directly under the given directory, with no directory entries.
File.name()is the bare filename on every driver (S3 previously returned the full key).FakeDriver.get_filesdelegates toLocalDriverrather than reimplementing the listing, so the test double cannot drift from what it stands in for.get_fileswas also missing from theStorageManager/Storagepassthroughs, so it was unreachable via the facade — added.File.contentstays lazy on S3 (the boto3ObjectSummary) rather than eagerly downloading every object body on a listing; local keeps its eager read. This difference is intentional and was confirmed with the PM.Verification
Real MinIO (
127.0.0.1:9002), bucket seeded withroot.txt,backups/a.sql,backups/b.sql,backups/2024/deep.sql,audio/x/y.mp3:Before the fix,
get_files('backups')returned[].New tests: 9 real MinIO integration tests (
test_s3_minio_integration.py, auto-skipped when no endpoint is reachable — overridable viaS3_TEST_ENDPOINT/S3_TEST_KEY/S3_TEST_SECRET), 16 mocked S3 unit tests for the prefix logic, 8 local-driver tests asserting real byte content, and FakeDriver↔LocalDriver parity tests over an identical layout.boto3added to the dev dependency group so the S3 path is testable at all.🤖 Generated with Claude Code
https://claude.ai/code/session_01FyozUDQzC8en24yx1Je1ct