Add GCSMediaStorage, and share a CloudMediaStorage base with S3 - #334
Open
regiscamimura wants to merge 9 commits into
Open
Add GCSMediaStorage, and share a CloudMediaStorage base with S3#334regiscamimura wants to merge 9 commits into
regiscamimura wants to merge 9 commits into
Conversation
The cloud SDKs are blocking, so every operation was written twice: a `_sync` method doing the work, and an async method running it in an executor. S3 and GCS had identical copies of that plumbing, along with `__init__`, `_prepend_folder_name`, and `__hash__`/`__eq__`. `CloudMediaStorage` now owns all of it. A backend only implements the six `_sync` methods and sets `provider_name` - `S3MediaStorage` uses it too, with no change to its public API. Also fixes a bug in `S3MediaStorage.store_file_sync`, which wrote the `ContentType` back into `self.upload_metadata`, so it leaked into the next upload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `__hash__` / `__eq__` now live on `MediaStorage`, driven by a
`_hash_components()` hook which subclasses extend. `MediaStorage`
previously defined `__eq__` with no `__hash__`, so Python set
`__hash__ = None` and the inherited `__eq__` was unusable until a
subclass supplied one. `LocalMediaStorage` and the cloud backends drop
their hand-rolled copies, and the `provider_name` attribute goes away -
the class name is already the discriminator.
- Added `folder_prefix` / `_remove_folder_name` to `CloudMediaStorage`, so
the folder convention is expressed once. This fixes S3's
`get_file_keys()`, which stripped the prefix with `str.lstrip()` - a
character-set strip, so `movie_posters/poster.jpg` came back as `.jpg`.
- `store_file_sync` is now concrete on the base, with an `upload_file`
hook per backend, so key generation and content-type lookup happen in
one place.
- Fixed S3's `bulk_delete_files_sync`, which sent the entire key list on
every iteration rather than the current batch, and whose batch bounds
were wrong - deleting over 1000 files failed outright.
- GCS `get_file` streams via `blob.open('rb')` instead of buffering the
whole object in memory, matching S3's behaviour.
- Folded the new equality tests into the existing `TestHash`, and made the
GCS fake record content types so they're actually asserted on.
The whole `piccolo_api/media` package is now at 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storage identity: - Reverted `_hash_components` to a per-provider literal. Keying it on `type(self).__name__` broke Piccolo Admin's duplicate-location check: someone who subclasses `S3MediaStorage` (the natural way to customise `get_client`) no longer compared equal to a plain `S3MediaStorage` for the same bucket, so two columns writing to one folder went undetected. - `MediaStorage._hash_components` now raises rather than defaulting to the class name. The default silently made every instance of a custom backend equal to every other, which would have turned that same check into a false accusation. - `__eq__` compares the components directly instead of their hashes, so a collision can't report two different storages as the same one. GCS: - Signed URLs never worked on GCP compute. `generate_signed_url` only uses the IAM `signBlob` API when both `service_account_email` and `access_token` are passed - otherwise it needs a private key, which Application Default Credentials don't have. The docstring told people to grant `roles/iam.serviceAccountTokenCreator`, but nothing ever issued the call it enables, so every URL raised `AttributeError`. `get_signing_kwargs` now supplies them when the credentials can't sign locally. - `get_file` went back to `download_as_bytes`. `blob.open()` doesn't touch the network until it's read, so a missing file surfaced in the caller rather than in `get_file` - both other backends raise straight away. - `bulk_delete_files` uses `delete_blobs(on_error=...)`, so one key that has already gone doesn't abandon the rest of the batch. S3's bulk delete is idempotent, so this matches it. - The client is cached. It was rebuilt per operation, and building one resolves credentials - on Cloud Run that's a metadata server round trip per file on an admin page, with the session never closed. Shared: - `_prepend_folder_name` derives from `folder_prefix` instead of using `pathlib`, so the two can't disagree. That fixes a `folder_name` with a trailing slash (files were stored under one prefix and listed under another) and the backslashes `pathlib` would have produced on Windows. - Lowercase the extension before the `CONTENT_TYPE` lookup, so `photo.JPG` gets `image/jpeg` rather than nothing. The GCS fake now raises `NotFound` like the real client, instead of tolerating missing blobs and hiding the two bugs above. `piccolo_api/media` is at 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `_run_sync` moved onto `MediaStorage` and now takes a zero-argument callable, so the call sites pass a lambda. That restores type checking: with `functools.partial` mypy checked neither the argument types nor the names, so renaming a `_sync` parameter stayed green and blew up in an executor thread. Verified both are caught now. - `LocalMediaStorage.bulk_delete_files` and `get_file_keys` did their file system work directly in the event loop, unlike every other method on the class. They go through the executor now. - `bulk_delete_files` ignores files which have already gone, on all three backends. GCS does it with `client.batch(raise_exception=False)`, which also packs 100 deletes per request rather than one request per file. - S3 and GCS cache their client. `get_client(config=None)` keeps its signature, and the unsigned client still goes through it, so a subclass overriding it to supply credentials still works. - Documented that the caller closes what `get_file` returns. The GCS fake now defers operations inside a batch, the way the real one does, so the batching test would fail if we weren't really batching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI:
- Neither test job installed the `gcs` extra, so `tests/media/test_gcs.py`
failed to import and the whole run aborted at collection. Added it to
all three jobs.
Regressions this branch had introduced:
- `folder_name` was normalised with `.strip("/")`, which also strips a
LEADING slash. Anyone using `folder_name="/movie_posters"` would have
had every key relocated, so their existing files would 404 and
`get_file_keys` wouldn't even list them for clean up. Normalising with
`PurePosixPath` instead reproduces master's keys byte-for-byte for
every input tested, while still keeping the prefix consistent.
- `__eq__` compared `_hash_components()` directly, which raises for a
custom backend that defines `__hash__` - the only way to write one
before this method existed. That turned piccolo_admin's duplicate
storage check into a `NotImplementedError` at startup. It compares
hashes again, as master did.
Other fixes:
- `client.batch(raise_exception=False)` swallowed every failed
sub-request, not just the missing files it was meant to tolerate: a
bucket the service account can't delete from would report a clean
sweep having deleted nothing. The batch applies every delete before
reporting, so catching `NotFound` around it keeps the tolerance and
lets a permissions error surface. This also drops the need for
google-cloud-storage >= 2.10, which is when that argument appeared -
the declared floor is 2.0.
- Locked the client caches. The check and the set were separate, so a
cold cache under concurrent requests built (and leaked) a client per
thread - exactly when the cache was meant to help.
- `MediaStorage.executor` was a bare annotation that was never assigned,
so a custom backend calling `_run_sync` got an `AttributeError` while
mypy reported success. It defaults to `None` now, which falls back to
the event loop's default executor.
- `LocalMediaStorage.bulk_delete_files` suppressed `FileNotFoundError`
so broadly that a missing media folder looked like a successful clean
up. `store_file` and `generate_file_url` are sync primitives now, like
the other backends, which also removes a latent deadlock when
`store_file_sync` was called from the storage's own executor.
- Scoped `follow_imports = "skip"` to `google.*`, rather than leaving it
applied to moto, botocore, httpx and qrcode as well.
The GCS fake now distinguishes a missing object from a permissions error,
and matches the real batch, which reports the first failure after
applying every operation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by the review, and both would have failed silently in production. Signing still didn't work on Cloud Run. `storage.Client` asks `google.auth.default` for the three `devstorage` scopes, and unlike Compute Engine, Cloud Run's metadata server honours the scopes it's asked for - so `credentials.token` is storage-scoped. Handing that to the IAM signBlob API, which only accepts `cloud-platform`, gets a 403 for insufficient scopes. So the previous commit fixed the missing signBlob call but still couldn't have signed a URL there. The credentials are re-scoped before signing now, and cached. The GCS bulk delete could hide a permissions error. `Batch` reports one failure per batch, and `exception_args = exception_args or subresponse` keeps the LAST one, because a 4xx `requests.Response` is falsy - so a chunk holding a 403 followed by an already-deleted key raises NotFound, which we then suppressed. A sweep that deleted nothing looked like it worked. Back to `delete_blobs(on_error=...)`, whose contract is exactly what's wanted: swallow NotFound, propagate everything else. That costs a request per file rather than per 100, which is the right trade for a maintenance command where silently skipping files is the bad outcome. `S3MediaStorage.bulk_delete_files_sync` had the same hole: `delete_objects` reports a key it couldn't delete in the response body instead of raising, and we never looked. CHANGES claimed errors were raised "on every backend", which wasn't true until now. Also corrected the changelog: normalising `folder_name` does NOT leave keys untouched on Windows, where they used to be built with a backslash. Anyone upgrading there has to rename their objects, so that's a warning now rather than a "nothing needs migrating". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`delete_blobs(on_error=...)` can't tell the 404 GCS gives for a missing object from the one it gives for a missing bucket, so a sweep against a mistyped or deleted bucket returned quietly having deleted nothing. S3 raises `NoSuchBucket` and local raises `FileNotFoundError` in the same situation, so GCS checks the bucket first now. Also corrected the comment about why we don't batch. A batch keeps the LAST of its failures, not the first: `exception_args = exception_args or subresponse` walks past earlier errors because a 4xx `requests.Response` is falsy. Verified. Added a test that a subclass of a backend still compares equal to the backend itself. That's the property Piccolo Admin's duplicate-location check depends on, and breaking it was a regression this branch shipped once already - it had been verified by hand but never pinned by a test. Both new tests were checked to fail when the behaviour they cover is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_store_and_retrieve` checked what it read back against the fake's own store, so an upload which wrote the wrong bytes would have satisfied both sides of the assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
regiscamimura
marked this pull request as ready for review
July 29, 2026 19:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #333.
Adds
GCSMediaStorage— and rather than a second copy of the S3 module, the shared object-storage plumbing moves intoCloudMediaStorage, whichS3MediaStoragenow uses too.Nothing else to wire up: Piccolo Admin's file field is driven by the
MediaStorageinterface, not by boto3 — so upload, preview, signed URLs and delete behave exactly as they do for S3.CloudMediaStorage
The cloud SDKs are blocking, so every operation was written twice — a
_syncmethod doing the work, and an async one running it in an executor. S3 and GCS had identical copies of that, plus__init__, the folder handling and__hash__/__eq__.The base class owns all of it now. A backend implements the
_syncmethods plusupload_file:S3MediaStorage's public API is unchanged —upload_metadataand itsendpoint_urlhashing stay in the subclass. Azure Blob would be a small module now, not a third copy.Storage identity also moved up to
MediaStorage, as a_hash_components()hook.MediaStoragedefined__eq__with no__hash__, which means Python sets__hash__ = None— so a custom backend written against the documented "subclassMediaStorage" path got aTypeErroron==unless it knew to add__hash__itself. Now it works by default, andLocalMediaStoragedrops its hand-rolled pair.Storage identity moved up to
MediaStoragetoo, as a_hash_components()hook — Piccolo Admin usesset(media_storage)to catch two columns writing to the same place, and each backend now declares what "the same place" means for it.MediaStoragepreviously defined__eq__with no__hash__, so a custom backend written against the documented "subclassMediaStorage" path hit aTypeErroron==unless it knew to add__hash__itself.Bugs fixed in S3 along the way
Found while unifying the two backends. Each has a regression test, and I checked each one fails without the fix:
get_file_keys()stripped the folder withstr.lstrip(prefix)— that removes characters, not a prefix. A file calledposter.jpgin amovie_postersfolder came back as.jpg, sodelete_unused_files()compared mangled keys against the database. The existing test passes only because its fixture is namedbulb.jpg.bulk_delete_files()sent the entire key list on every iteration instead of the current batch, and(iteration + 1 * batch_size)doesn't group the way it reads. Over 1000 keys, S3 rejects the request.store_file_sync()wroteContentTypeback intoself.upload_metadata. It leaked into the next file with an unknown extension — and with concurrent uploads it could be applied to the wrong file entirely, since the executor runs 10 at a time.bulk_delete_files()never looked at theErrorsarraydelete_objectsreturns. S3 reports a key it couldn't delete in the response body rather than raising, so cleaning a bucket withouts3:DeleteObjectreturned quietly having deleted nothing.folder_namewith a trailing slash stored files undermovie_posters/but listed them undermovie_posters//, sodelete_unused_files()silently found nothing._prepend_folder_namenow derives from the samefolder_prefixthe listing uses, so they can't drift (this also removes apathlibcall that would emit backslashes on Windows).photo.JPGuploaded with noContentType, becauseCONTENT_TYPEis keyed by lowercase extension whilevalidate_file_namelowercases and this didn't. Browsers downloaded the image instead of showing it.Happy to split these into a separate PR if you'd rather review them apart from the GCS work.
Testing
You mentioned the Azure PR stalled because it was hard to test, so GCS runs against a faked
storage.Client— no live bucket, no credentials.piccolo_api/mediais at 100% coverage.Signed URLs on GCP compute took two goes to get right, so it's worth spelling out:
blob.generate_signed_url()only routes through the IAMsignBlobAPI if you pass bothservice_account_emailandaccess_token. Without them it wants a private key, which ADC from the metadata server doesn't have — so grantingroles/iam.serviceAccountTokenCreatoron its own does nothing, and every URL raisesAttributeError.storage.Clientasksgoogle.auth.defaultfor the threedevstoragescopes, and — unlike Compute Engine — Cloud Run's metadata server honours the scopes it's asked for. IAMsignBlobonly acceptscloud-platform, so that token gets a 403.So the credentials are re-scoped to
cloud-platformbefore signing, and cached. A service-account JSON still signs locally and never touches IAM.I can't test this against live GCP — the branch logic is covered by fakes, but if anyone can try it on Cloud Run, that's the bit worth checking.
The GCS fake deliberately behaves like the real client where it matters: it defers operations inside a batch, distinguishes a missing object from a permissions error, and reports the first failure after applying every operation. So the batching test fails if the code isn't really batching, rather than passing regardless.
Also worth noting: CI didn't install the
gcsextra, sotests/media/test_gcs.pyaborted collection and took the whole run with it. Fixed in all three jobs.Consistency fixes across all three backends
delete_blobs(on_error=...), notclient.batch(). Batching would be 100× fewer requests, but a batch reports only one of its failures andBatch._finish_futureskeeps the last one (a 4xxResponseis falsy, soexception_args or subresponsewalks past earlier errors) — so a permissions error can hide behind an already-deleted key and the sweep looks like it worked. For a delete, silently skipping files is worse than being slow.bulk_delete_filestolerates a file that has already gone on every backend, so one stale key can't abandon the sweep. Anything else — a permissions error, say — still raises. A single delete still raises if the file is missing; naming one file means you want to know.LocalMediaStoragefollows the same convention as the cloud ones now (_syncdoes the work, async wraps it). Itsbulk_delete_filesandget_file_keyswere doing filesystem work directly in the event loop.Known limitation
On GCP compute, each signed URL costs an IAM
signBlobrequest, because ADC can't sign locally. Documented in the docstring, with the two ways out (mount a key, orsign_urls=Falsewith a public bucket). Client caching doesn't help with this part.get_file_keys()also lists objects in nested folders under the prefix, whichdelete_unused_files()will then remove since they never match a database value. That behaviour is unchanged frommaster— flagging it rather than changing it, since a shared bucket is arguably a misconfiguration and the fix would be a semantic change.