Skip to content

Add GCSMediaStorage, and share a CloudMediaStorage base with S3 - #334

Open
regiscamimura wants to merge 9 commits into
piccolo-orm:masterfrom
regiscamimura:gcs-media-storage
Open

Add GCSMediaStorage, and share a CloudMediaStorage base with S3#334
regiscamimura wants to merge 9 commits into
piccolo-orm:masterfrom
regiscamimura:gcs-media-storage

Conversation

@regiscamimura

@regiscamimura regiscamimura commented Jul 29, 2026

Copy link
Copy Markdown

Closes #333.

Adds GCSMediaStorage — and rather than a second copy of the S3 module, the shared object-storage plumbing moves into CloudMediaStorage, which S3MediaStorage now uses too.

from piccolo_api.media.gcs import GCSMediaStorage

MOVIE_POSTER_MEDIA = GCSMediaStorage(
    column=Movie.poster,
    bucket_name="my-bucket",
    folder_name="movie_posters",
)

Nothing else to wire up: Piccolo Admin's file field is driven by the MediaStorage interface, 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 _sync method 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 _sync methods plus upload_file:

class GCSMediaStorage(CloudMediaStorage):
    def upload_file(self, file_key, file, content_type): ...
    def generate_file_url_sync(self, file_key, root_url, user=None) -> str: ...
    def get_file_sync(self, file_key): ...
    def delete_file_sync(self, file_key): ...
    def bulk_delete_files_sync(self, file_keys): ...
    def get_file_keys_sync(self) -> list[str]: ...

S3MediaStorage's public API is unchanged — upload_metadata and its endpoint_url hashing 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. MediaStorage defined __eq__ with no __hash__, which means Python sets __hash__ = None — so a custom backend written against the documented "subclass MediaStorage" path got a TypeError on == unless it knew to add __hash__ itself. Now it works by default, and LocalMediaStorage drops its hand-rolled pair.

Storage identity moved up to MediaStorage too, as a _hash_components() hook — Piccolo Admin uses set(media_storage) to catch two columns writing to the same place, and each backend now declares what "the same place" means for it. MediaStorage previously defined __eq__ with no __hash__, so a custom backend written against the documented "subclass MediaStorage" path hit a TypeError on == 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 with str.lstrip(prefix) — that removes characters, not a prefix. A file called poster.jpg in a movie_posters folder came back as .jpg, so delete_unused_files() compared mangled keys against the database. The existing test passes only because its fixture is named bulb.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() wrote ContentType back into self.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 the Errors array delete_objects returns. S3 reports a key it couldn't delete in the response body rather than raising, so cleaning a bucket without s3:DeleteObject returned quietly having deleted nothing.
  • A folder_name with a trailing slash stored files under movie_posters/ but listed them under movie_posters//, so delete_unused_files() silently found nothing. _prepend_folder_name now derives from the same folder_prefix the listing uses, so they can't drift (this also removes a pathlib call that would emit backslashes on Windows).
  • photo.JPG uploaded with no ContentType, because CONTENT_TYPE is keyed by lowercase extension while validate_file_name lowercases 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/media is at 100% coverage.

Signed URLs on GCP compute took two goes to get right, so it's worth spelling out:

  1. blob.generate_signed_url() only routes through the IAM signBlob API if you pass both service_account_email and access_token. Without them it wants a private key, which ADC from the metadata server doesn't have — so granting roles/iam.serviceAccountTokenCreator on its own does nothing, and every URL raises AttributeError.
  2. Passing the client's own token isn't enough either. 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. IAM signBlob only accepts cloud-platform, so that token gets a 403.

So the credentials are re-scoped to cloud-platform before 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 gcs extra, so tests/media/test_gcs.py aborted collection and took the whole run with it. Fixed in all three jobs.

Consistency fixes across all three backends

  • Both cloud backends cache their client (building one resolves credentials — on Cloud Run that's a metadata round trip per file on a list page). The caches are locked, so a cold cache under concurrent requests doesn't build one per thread.
  • GCS deletes use delete_blobs(on_error=...), not client.batch(). Batching would be 100× fewer requests, but a batch reports only one of its failures and Batch._finish_futures keeps the last one (a 4xx Response is falsy, so exception_args or subresponse walks 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_files tolerates 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.
  • LocalMediaStorage follows the same convention as the cloud ones now (_sync does the work, async wraps it). Its bulk_delete_files and get_file_keys were doing filesystem work directly in the event loop.

Known limitation

On GCP compute, each signed URL costs an IAM signBlob request, because ADC can't sign locally. Documented in the docstring, with the two ways out (mount a key, or sign_urls=False with a public bucket). Client caching doesn't help with this part.

get_file_keys() also lists objects in nested folders under the prefix, which delete_unused_files() will then remove since they never match a database value. That behaviour is unchanged from master — flagging it rather than changing it, since a shared bucket is arguably a misconfiguration and the fix would be a semantic change.

regiscamimura and others added 9 commits July 27, 2026 11:37
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
regiscamimura marked this pull request as ready for review July 29, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: expand media-storage cloud backends — Google Cloud Storage first

1 participant