Skip to content

Commit 28b597f

Browse files
committed
Added support for Google Cloud Storage in image uploads, including environment-based backend selection. Expanded related tests and updated documentation.
1 parent 47023b8 commit 28b597f

4 files changed

Lines changed: 271 additions & 51 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ from lf_toolkit.chat import ChatRequest, ChatResponse, Message
159159

160160
## Image Upload
161161

162-
Upload PIL images to S3 using AWS SigV4 authentication:
162+
Upload PIL images to AWS S3 or Google Cloud Storage:
163163

164164
```python
165165
from PIL import Image
@@ -169,7 +169,17 @@ img = Image.open("diagram.png")
169169
url = upload_image(img, folder_name="my-eval-function")
170170
```
171171

172-
Required environment variables:
172+
### Backend selection
173+
174+
`upload_image` picks a backend at call time:
175+
176+
1. `IMAGE_UPLOAD_BACKEND` env var, when set to `s3` or `gcs`, wins.
177+
2. Otherwise, if `GCS_BUCKET` is set the GCS backend is used.
178+
3. Otherwise S3 is used (the default — existing deployments are unaffected).
179+
180+
### S3 backend
181+
182+
Uses AWS SigV4-signed `PUT` requests.
173183

174184
| Variable | Description |
175185
|---|---|
@@ -179,6 +189,19 @@ Required environment variables:
179189
| `AWS_SESSION_TOKEN` | (optional) Session token |
180190
| `AWS_REGION` | AWS region (default: `eu-west-2`) |
181191

192+
### GCS backend
193+
194+
Requires the `gcs` extra (`poetry install --extras gcs`, or
195+
`lf_toolkit = { ..., extras = ["gcs"] }`). Authenticates with Application
196+
Default Credentials (the runtime service account on Cloud Run / GKE / GCE) — no
197+
static keys. The target bucket / prefix must be readable by whoever consumes the
198+
returned URL (e.g. `roles/storage.objectViewer` for `allUsers`).
199+
200+
| Variable | Description |
201+
|---|---|
202+
| `GCS_BUCKET` | Target bucket name |
203+
| `GCS_PUBLIC_BASE_URL` | (optional) URL host for the returned link (default: `https://storage.googleapis.com`) |
204+
182205
## Set Notation Parser
183206

184207
Parse and evaluate set expressions (requires `parsing` extra):

lf_toolkit/evaluation/image_upload.py

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
from botocore.awsrequest import AWSRequest
1313
from botocore.credentials import Credentials
1414

15+
try:
16+
from google.cloud import storage as _gcs_storage
17+
except ImportError: # pragma: no cover - optional dependency, install the "gcs" extra
18+
_gcs_storage = None
19+
1520
load_dotenv()
1621

1722
MIME_TO_FORMAT: Dict[str, List[str]] = {
@@ -69,6 +74,19 @@ def get_s3_bucket_uri() -> str:
6974
return s3_uri
7075

7176

77+
def resolve_upload_backend() -> str:
78+
"""Decide which storage backend upload_image() should use.
79+
80+
``IMAGE_UPLOAD_BACKEND`` ("s3" or "gcs") wins when set to a known value.
81+
Otherwise the presence of ``GCS_BUCKET`` selects GCS, and S3 is the default
82+
so existing deployments keep working unchanged.
83+
"""
84+
backend = (os.getenv('IMAGE_UPLOAD_BACKEND') or '').strip().lower()
85+
if backend in ('s3', 'gcs'):
86+
return backend
87+
return 'gcs' if os.getenv('GCS_BUCKET') else 's3'
88+
89+
7290
def get_aws_signed_request(full_url, buffer, mime_type):
7391
credentials = Credentials(
7492
access_key=os.environ['AWS_ACCESS_KEY_ID'],
@@ -113,57 +131,97 @@ def get_aws_signed_request(full_url, buffer, mime_type):
113131
return aws_request
114132

115133

134+
def _upload_s3(folder_name: str, filename: str, data: bytes, mime_type: str) -> str:
135+
"""Upload bytes to S3 with a SigV4-signed PUT and return the object URL."""
136+
base_url: str = get_s3_bucket_uri()
137+
full_url = os.path.join(base_url, folder_name, filename)
138+
139+
aws_request = get_aws_signed_request(full_url, data, mime_type).prepare()
140+
141+
response: requests.Response = requests.request(
142+
method=aws_request.method,
143+
url=aws_request.url,
144+
data=aws_request.body,
145+
headers=aws_request.headers,
146+
timeout=30
147+
)
148+
149+
if response.status_code != 200:
150+
raise ImageUploadError(
151+
f"Upload failed with status code {response.status_code}: {response.text}"
152+
)
153+
154+
return full_url
155+
156+
157+
def _upload_gcs(folder_name: str, filename: str, data: bytes, mime_type: str) -> str:
158+
"""Upload bytes to Google Cloud Storage and return the object URL.
159+
160+
Authenticates with Application Default Credentials (the runtime service
161+
account on Cloud Run / GKE / GCE) -- no static keys. Set ``GCS_BUCKET`` to
162+
the target bucket and, optionally, ``GCS_PUBLIC_BASE_URL`` to override the
163+
returned URL's host (e.g. a CDN or custom domain).
164+
"""
165+
if _gcs_storage is None:
166+
raise ImageUploadError(
167+
"google-cloud-storage is not installed; install lf_toolkit with the "
168+
"'gcs' extra to use IMAGE_UPLOAD_BACKEND=gcs"
169+
)
170+
171+
bucket_name: Optional[str] = os.getenv('GCS_BUCKET')
172+
if not bucket_name:
173+
raise MissingEnvironmentVariableError(
174+
"GCS_BUCKET environment variable is not set"
175+
)
176+
177+
blob_name = f"{folder_name}/{filename}"
178+
client = _gcs_storage.Client()
179+
blob = client.bucket(bucket_name).blob(blob_name)
180+
blob.upload_from_string(data, content_type=mime_type)
181+
182+
base_url = os.getenv('GCS_PUBLIC_BASE_URL', 'https://storage.googleapis.com').rstrip('/')
183+
return f"{base_url}/{bucket_name}/{blob_name}"
184+
185+
116186
def upload_image(img: Image.Image, folder_name: str) -> str:
117-
"""Upload PIL image with comprehensive MIME type validation
187+
"""Upload a PIL image to the configured storage backend.
188+
189+
The backend is chosen by :func:`resolve_upload_backend` (env var
190+
``IMAGE_UPLOAD_BACKEND=s3|gcs``, else auto-detected from ``GCS_BUCKET`` /
191+
``S3_BUCKET_URI``, defaulting to S3).
118192
119193
Args:
120-
folder_name: name of folder to save image
121194
img: PIL Image object to upload
195+
folder_name: name of the folder/prefix to store the image under
122196
123197
Returns:
124-
JSON response from the server as a dictionary
198+
The public URL of the uploaded object
125199
126200
Raises:
127201
InvalidMimeTypeError: If MIME type validation fails
128-
MissingEnvironmentVariableError: If S3_BUCKET_URI is not set
202+
MissingEnvironmentVariableError: If required env vars are not set
129203
ImageUploadError: If upload fails for any reason
130204
"""
131205
try:
132-
# Get URL from environment variable
133-
base_url: str = get_s3_bucket_uri()
134-
135206
filename: str = generate_file_name(img)
136207

137-
full_url = os.path.join(base_url, folder_name, filename)
138-
139208
if img.format is None:
140209
img.format = 'PNG'
141210

142211
mime_type = FORMAT_TO_MIME[img.format.upper()]
143212

144213
buffer: BytesIO = BytesIO()
145-
img_format: str = img.format if img.format else 'PNG'
146-
img.save(buffer, format=img_format)
147-
buffer.seek(0)
148-
149-
aws_request = get_aws_signed_request(full_url, buffer, mime_type).prepare()
150-
151-
response: requests.Response = requests.request(
152-
method=aws_request.method,
153-
url=aws_request.url,
154-
data=aws_request.body,
155-
headers=aws_request.headers,
156-
timeout=30
157-
)
158-
159-
if response.status_code != 200:
160-
raise ImageUploadError(
161-
f"Upload failed with status code {response.status_code}: {response.text}"
162-
)
214+
img.save(buffer, format=img.format)
215+
data: bytes = buffer.getvalue()
163216

164-
return full_url
217+
backend = resolve_upload_backend()
218+
if backend == 'gcs':
219+
return _upload_gcs(folder_name, filename, data, mime_type)
220+
return _upload_s3(folder_name, filename, data, mime_type)
165221

166-
except (InvalidMimeTypeError, MissingEnvironmentVariableError):
222+
except ImageUploadError:
223+
# InvalidMimeTypeError / MissingEnvironmentVariableError / backend errors
224+
# already carry a useful message -- propagate as-is.
167225
raise
168226
except requests.exceptions.RequestException as e:
169227
raise ImageUploadError(f"Network error: {str(e)}")

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ fastapi = { version = "^0.115.0", optional = true }
3838

3939
pywin32 = { version = "^306", platform = "win32", optional = true }
4040

41+
##########################
42+
# gcs image-upload dependencies
43+
##########################
44+
45+
google-cloud-storage = { version = "^2.18", optional = true }
46+
4147
##########################
4248
# dev dependencies
4349
##########################
@@ -66,6 +72,7 @@ datamodel-code-generator = "^0.55.0"
6672
[tool.poetry.extras]
6773
parsing = ["antlr4-python3-runtime", "lark", "latex2sympy"]
6874
ipc = ["pywin32"]
75+
gcs = ["google-cloud-storage"]
6976
http = ["fastapi"]
7077

7178
[tool.isort]

0 commit comments

Comments
 (0)