Skip to content

Commit e9bbb5d

Browse files
authored
Merge pull request #80 from octue/access-ingress-path
Access ingress path
2 parents 89392ea + af7a9ea commit e9bbb5d

File tree

8 files changed

+257
-101
lines changed

8 files changed

+257
-101
lines changed

django_gcp/storage/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# TODO: Refactor tests to import members directly then remove this module export
22
from . import gcloud
3-
from .blob_utils import BlobFieldMixin, get_blob, get_blob_name, get_path, get_signed_url, upload_blob
3+
from .blob_utils import BlobFieldMixin, get_blob, get_blob_name, get_path, get_signed_url
44
from .gcloud import GoogleCloudFile, GoogleCloudMediaStorage, GoogleCloudStaticStorage, GoogleCloudStorage
5+
from .operations import upload_blob
56

67
__all__ = [
78
"GoogleCloudStorage",

django_gcp/storage/blob_utils.py

Lines changed: 7 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,14 @@
11
from datetime import timedelta
22
from os.path import split
33

4-
from google.cloud.storage.blob import Blob
5-
6-
7-
def upload_blob(
8-
instance,
9-
field_name,
10-
local_path,
11-
destination_path=None,
12-
attributes=None,
13-
allow_overwrite=False,
14-
existing_path=None,
15-
):
16-
"""Upload a file to the cloud store, using the instance and field name to determine the store details
17-
18-
You might use this utility to upload fixtue files for integration tests, as part of
19-
a migration, or as part of a function creating local files. The field's own logic for generating paths
20-
is used by default, although this can be overridden.
21-
22-
Returns the field value so you can use this to construct instances directly:
23-
24-
# Directly set the instance field without processing the blob ingress
25-
with override_settings(GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True):
26-
instance.field_name = upload_blob(...)
27-
28-
:param django.db.Model instance: An instance of a django Model which has a BlobField
29-
:param str field_name: The name of the BlobField attribute on the instance
30-
:param str local_path: The path to the file to upload
31-
:param str destination_path: The path to upload the file to. If None, the remote path will be generated
32-
from the BlobField. If setting this value, take care to override the value of the field on the instance
33-
so it's path matches; this is not updated for you.
34-
:param dict attributes: A dictionary of attributes to set on the blob eg content type
35-
:param bool allow_overwrite: If true, allows existing blobs at the path to be overwritten. If destination_path is not given, this is provided to the get_destination_path callback (and may be overridden by that callback per its specification)
36-
:param str existing_path: If destination_path is None, this is provided to the get_destination_path callback to simulate behaviour where there is an existing path
37-
"""
38-
# Get the field (which
39-
field = instance._meta.get_field(field_name)
40-
if destination_path is None:
41-
destination_path, allow_overwrite = field.get_destination_path(
42-
instance,
43-
original_name=local_path,
44-
attributes=attributes,
45-
allow_overwrite=allow_overwrite,
46-
existing_path=existing_path,
47-
bucket=field.storage.bucket,
48-
)
49-
50-
# If not allowing overwrite, set generation matching constraints to prevent it
51-
if_generation_match = None if allow_overwrite else 0
52-
53-
# Attributes must be a dict by default
54-
attributes = attributes or {}
55-
56-
# Upload the file
57-
Blob(destination_path, bucket=field.storage.bucket).upload_from_filename(
58-
local_path, if_generation_match=if_generation_match, **attributes
59-
)
60-
61-
# Return the field value
62-
return {"path": destination_path}
63-
644

655
def get_path(instance, field_name):
666
"""Get the path of the blob in the object store"""
677
field_value = getattr(instance, field_name)
688
return field_value.get("path", None) if field_value is not None else None
699

7010

71-
def get_blob(instance, field_name):
11+
def get_blob(instance, field_name, reload=True):
7212
"""Get a blob from a model instance containing a BlobField
7313
7414
This allows you to download the blob to a local file. For example:
@@ -85,11 +25,16 @@ def get_blob(instance, field_name):
8525
8626
:param django.db.Model instance: An instance of a django Model which has a BlobField
8727
:param str field_name: The name of the BlobField attribute on the instance
28+
:param bool reload: Default True. If it is not essential to have up-to-date information from the store, speed up the call to get_blob using call with reload_blob=False
8829
"""
8930
path = get_path(instance, field_name)
9031
if path is not None:
9132
field = instance._meta.get_field(field_name)
92-
return field.storage.bucket.blob(path)
33+
blob = field.storage.bucket.blob(path)
34+
if reload:
35+
blob.reload()
36+
37+
return blob
9338

9439

9540
def get_blob_name(instance, field_name):

django_gcp/storage/fields.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def __init__(
105105
max_size_bytes=DEFAULT_MAX_SIZE_BYTES,
106106
overwrite_mode=DEFAULT_OVERWRITE_MODE,
107107
on_change=None,
108+
update_attributes=None,
108109
**kwargs,
109110
):
110111
self._validated = False
@@ -117,7 +118,9 @@ def __init__(
117118
self._choices_set_explicitly = "choices" in kwargs
118119
self.overwrite_mode = overwrite_mode
119120
self.get_destination_path = get_destination_path
121+
self.update_attributes = update_attributes
120122
self.ingress_to = ingress_to
123+
self.ingress_path = None
121124
self.store_key = store_key
122125
self.accept_mimetype = accept_mimetype
123126
self.max_size_bytes = max_size_bytes
@@ -152,6 +155,7 @@ def check(self, **kwargs):
152155
*super().check(**kwargs),
153156
*self._check_explicit(),
154157
*self._check_get_destination_path(),
158+
*self._check_update_attributes(),
155159
*self._check_ingress_to(),
156160
*self._check_overwrite_mode(),
157161
*self._check_on_change(),
@@ -164,6 +168,7 @@ def deconstruct(self):
164168
kwargs["accept_mimetype"] = self.accept_mimetype
165169
kwargs["overwrite_mode"] = self.overwrite_mode
166170
kwargs["get_destination_path"] = self.get_destination_path
171+
kwargs["update_attributes"] = self.update_attributes
167172
kwargs["on_change"] = self.on_change
168173
return name, path, args, kwargs
169174

@@ -248,15 +253,29 @@ def on_commit_blank():
248253

249254
elif adding_valid or updating_blank_to_valid or updating_valid_to_valid:
250255
new_value = {}
256+
257+
allow_overwrite = self._get_allow_overwrite(add)
258+
259+
attributes = self._update_attributes(
260+
getattr(value, "attributes", {}),
261+
instance=model_instance,
262+
original_name=value["name"],
263+
existing_path=existing_path,
264+
temporary_path=value["_tmp_path"],
265+
adding=add,
266+
bucket=self.storage.bucket,
267+
)
268+
251269
new_value["path"], allow_overwrite = self._get_destination_path(
252270
instance=model_instance,
253271
original_name=value["name"],
254-
attributes=getattr(value, "attributes", None),
272+
attributes=attributes,
255273
allow_overwrite=self._get_allow_overwrite(add),
256274
existing_path=existing_path,
257275
temporary_path=value["_tmp_path"],
258276
bucket=self.storage.bucket,
259277
)
278+
260279
logger.info(
261280
"Adding/updating cloud object via temporary ingress at %s to %s",
262281
value["_tmp_path"],
@@ -275,7 +294,7 @@ def on_commit_valid():
275294
new_value["path"],
276295
move=True,
277296
overwrite=allow_overwrite,
278-
attributes=value.get("attributes", None),
297+
attributes=attributes,
279298
)
280299
if self.on_change is not None:
281300
self.on_change(new_value, instance=model_instance)
@@ -482,6 +501,17 @@ def _check_get_destination_path(self):
482501
]
483502
return []
484503

504+
def _check_update_attributes(self):
505+
if self.update_attributes is not None and not callable(self.update_attributes):
506+
return [
507+
checks.Error(
508+
f"'update_attributes' argument in {self.__class__.__name__} must be None, or a callable function that updates attributes to be set on ingressed blobs.",
509+
obj=self,
510+
id="fields.E201",
511+
)
512+
]
513+
return []
514+
485515
def _check_on_change(self):
486516
if self.on_change is not None and not callable(self.on_change):
487517
return [
@@ -604,3 +634,17 @@ def _get_destination_path(self, *args, **kwargs):
604634
self.get_destination_path,
605635
)
606636
return get_destination_path(*args, **kwargs)
637+
638+
def _update_attributes(self, attributes, **kwargs):
639+
"""Call the update_attributes callback unless an override is defined in
640+
settings. This funcitonality is intended for test purposes only, because
641+
patching the callback in a test framework is a struggle
642+
"""
643+
update_attributes = getattr(
644+
settings,
645+
"GCP_STORAGE_OVERRIDE_UPDATE_ATTRIBUTES_CALLBACK",
646+
self.update_attributes,
647+
)
648+
if update_attributes is not None:
649+
return update_attributes(attributes, **kwargs)
650+
return attributes

django_gcp/storage/operations.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import datetime
22
import logging
3+
34
from django.utils import timezone
4-
from django_gcp.exceptions import AttemptedOverwriteError, MissingBlobError
55
from google.cloud.exceptions import NotFound, PreconditionFailed
6+
from google.cloud.storage.blob import Blob
67

8+
from django_gcp.exceptions import AttemptedOverwriteError, MissingBlobError
79

810
logger = logging.getLogger(__name__)
911

@@ -14,6 +16,64 @@ def blob_exists(bucket, blob_name):
1416
return blob.exists()
1517

1618

19+
def upload_blob(
20+
instance,
21+
field_name,
22+
local_path,
23+
destination_path=None,
24+
attributes=None,
25+
allow_overwrite=False,
26+
existing_path=None,
27+
):
28+
"""Upload a file to the cloud store, using the instance and field name to determine the store details
29+
30+
You might use this utility to upload fixtue files for integration tests, as part of
31+
a migration, or as part of a function creating local files. The field's own logic for generating paths
32+
is used by default, although this can be overridden.
33+
34+
Returns the field value so you can use this to construct instances directly:
35+
36+
# Directly set the instance field without processing the blob ingress
37+
with override_settings(GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True):
38+
instance.field_name = upload_blob(...)
39+
40+
:param django.db.Model instance: An instance of a django Model which has a BlobField
41+
:param str field_name: The name of the BlobField attribute on the instance
42+
:param str local_path: The path to the file to upload
43+
:param str destination_path: The path to upload the file to. If None, the remote path will be generated
44+
from the BlobField. If setting this value, take care to override the value of the field on the instance
45+
so it's path matches; this is not updated for you.
46+
:param dict attributes: A dictionary of attributes to set on the blob eg content type
47+
:param bool allow_overwrite: If true, allows existing blobs at the path to be overwritten. If destination_path is not given, this is provided to the get_destination_path callback (and may be overridden by that callback per its specification)
48+
:param str existing_path: If destination_path is None, this is provided to the get_destination_path callback to simulate behaviour where there is an existing path
49+
"""
50+
# Get the field (which
51+
field = instance._meta.get_field(field_name)
52+
if destination_path is None:
53+
destination_path, allow_overwrite = field.get_destination_path(
54+
instance,
55+
original_name=local_path,
56+
attributes=attributes,
57+
allow_overwrite=allow_overwrite,
58+
existing_path=existing_path,
59+
bucket=field.storage.bucket,
60+
)
61+
62+
# If not allowing overwrite, set generation matching constraints to prevent it
63+
if_generation_match = None if allow_overwrite else 0
64+
65+
# Attributes must be a dict by default
66+
attributes = attributes or {}
67+
68+
# Upload the file
69+
Blob(destination_path, bucket=field.storage.bucket).upload_from_filename(
70+
local_path, if_generation_match=if_generation_match, **attributes
71+
)
72+
73+
# Return the field value
74+
return {"path": destination_path}
75+
76+
1777
def copy_blob(
1878
source_bucket,
1979
source_blob_name,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-gcp"
3-
version = "0.16.1"
3+
version = "0.17.0"
44
description = "Utilities to run Django on Google Cloud Platform"
55
authors = ["Tom Clark"]
66
license = "MIT"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 4.2.19 on 2025-03-20 13:00
2+
3+
from django.db import migrations, models
4+
import django_gcp.storage.fields
5+
import tests.server.example.models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("example", "0007_exampleneveroverwriteblobfieldmodel_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="ExampleUpdateAttributesBlobFieldModel",
16+
fields=[
17+
(
18+
"id",
19+
models.AutoField(
20+
auto_created=True,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name="ID",
24+
),
25+
),
26+
("category", models.CharField(blank=True, max_length=20, null=True)),
27+
(
28+
"blob",
29+
django_gcp.storage.fields.BlobField(
30+
accept_mimetype="*/*",
31+
blank=True,
32+
default=None,
33+
get_destination_path=tests.server.example.models.get_destination_path,
34+
help_text="On save, metadata should be updated on the blob.",
35+
ingress_to="_tmp/",
36+
null=True,
37+
on_change=None,
38+
overwrite_mode="never",
39+
store_key="media",
40+
update_attributes=tests.server.example.models.update_attributes,
41+
),
42+
),
43+
],
44+
),
45+
]

tests/server/example/models.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,45 @@ class Meta:
363363
"""Metaclass defining this model to reside in the example app"""
364364

365365
app_label = "example"
366+
367+
368+
def update_attributes(attributes, instance, original_name, existing_path, temporary_path, adding, bucket):
369+
"""Update the attributes set on the blob upon move to its destination path
370+
371+
These attributes can be any valid attributes that are set on GCP blobs, such as content-type and metadata.
372+
373+
This callback is executed prior to the save() method so the file will be residing in the ingress directory.
374+
It's advised not to download the file during this stage due to the duration of that operation (we recommend that
375+
if full file download is required, these attributes are set later in a separate or async task).
376+
377+
In some cases we've seen the first few bytes of files being streamed in order to read and extract values from headers
378+
which is a neat approach.
379+
380+
See https://cloud.google.com/python/docs/reference/storage/latest/google.cloud.storage.blob.Blob
381+
"""
382+
attributes["content_type"] = "image/png"
383+
attributes["metadata"] = {"category": instance.category}
384+
return attributes
385+
386+
387+
class ExampleUpdateAttributesBlobFieldModel(Model):
388+
"""
389+
As ExampleBlobFieldModel but showing use of the update_attributes callback
390+
(This is mostly used for widget development and testing)
391+
"""
392+
393+
category = CharField(max_length=20, blank=True, null=True)
394+
395+
blob = BlobField(
396+
get_destination_path=get_destination_path,
397+
update_attributes=update_attributes,
398+
store_key="media",
399+
blank=True,
400+
null=True,
401+
help_text="On save, metadata should be updated on the blob.",
402+
)
403+
404+
class Meta:
405+
"""Metaclass defining this model to reside in the example app"""
406+
407+
app_label = "example"

0 commit comments

Comments
 (0)