Skip to content

Commit afbf4b7

Browse files
authored
Allow storage extra & headers attributes (#18)
* Allow storage extra attributes * Add tests for extra attributes * Update docs for extra attributes
1 parent bdb9ba1 commit afbf4b7

File tree

12 files changed

+294
-60
lines changed

12 files changed

+294
-60
lines changed

docs/tutorial/using-files-in-models.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,59 @@ the default attributes used by [File][sqlalchemy_file.file.File] object internal
132132
[File][sqlalchemy_file.file.File] provides also attribute style access.
133133
You can access your keys as attributes.
134134

135+
### Extra & Headers
136+
137+
`Apache-libcloud` allow you to store each object with some `extra` attributes or additional `headers`.
138+
139+
They are two ways to add `extra` and `headers` with *sqlalchemy-file*
140+
141+
- on field declaration (shared by all associated files)
142+
143+
```python
144+
145+
class Attachment(Base):
146+
__tablename__ = "attachment"
147+
148+
id = Column(Integer, autoincrement=True, primary_key=True)
149+
name = Column(String(50), unique=True)
150+
content = Column(
151+
FileField(
152+
extra={
153+
"acl": "private",
154+
"dummy_key": "dummy_value",
155+
"meta_data": {"key1": "value1", "key2": "value2"},
156+
},
157+
headers={
158+
"Access-Control-Allow-Origin": "http://test.com",
159+
"Custom-Key": "xxxxxxx",
160+
},
161+
)
162+
)
163+
164+
```
165+
166+
- in your [File][sqlalchemy_file.file.File] object
167+
168+
!!! important
169+
When the Field has default `extra` attribute, it's overridden by [File][sqlalchemy_file.file.File] object `extra`
170+
attribute
171+
```python hl_lines="4"
172+
with Session(engine) as session:
173+
attachment = Attachment(
174+
name="Public document",
175+
content=File(DummyFile(), extra={"acl": "public-read"}),
176+
)
177+
session.add(attachment)
178+
session.commit()
179+
session.refresh(attachment)
180+
181+
assert attachment.content.file.object.extra["acl"] == "public-read"
182+
```
135183
### Metadata
136184

185+
!!! warning
186+
This attribute is now deprecated, migrate to [extra](#extra-headers)
187+
137188
*SQLAlchemy-file* store the uploaded file with some metadata. Only `filename` and `content_type` are sent by default,
138189
. You can complete with `metadata` key inside your [File][sqlalchemy_file.file.File] object.
139190

@@ -153,7 +204,7 @@ the default attributes used by [File][sqlalchemy_file.file.File] object internal
153204

154205
## Uploading on a Specific Storage
155206

156-
By default all the files are uploaded on the default storage which is the first added storage. This can be changed
207+
By default, all the files are uploaded on the default storage which is the first added storage. This can be changed
157208
by passing a `upload_storage` argument explicitly on field declaration:
158209

159210
```Python
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from sqlalchemy import Column, Integer, String
2+
from sqlalchemy.orm import declarative_base
3+
from sqlalchemy_file.types import FileField
4+
5+
Base = declarative_base()
6+
7+
8+
class Attachment(Base):
9+
__tablename__ = "attachment"
10+
11+
id = Column(Integer, autoincrement=True, primary_key=True)
12+
name = Column(String(50), unique=True)
13+
content = Column(
14+
FileField(
15+
extra={
16+
"acl": "private",
17+
"dummy_key": "dummy_value",
18+
"meta_data": {"key1": "value1", "key2": "value2"},
19+
},
20+
headers={
21+
"Access-Control-Allow-Origin": "http://test.com",
22+
"Custom-Key": "xxxxxxx",
23+
},
24+
)
25+
)

scripts/format.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
set -e
44
set -x
5-
ruff sqlalchemy_file tests --fix
5+
ruff sqlalchemy_file tests docs_src --fix
66
black .

sqlalchemy_file/file.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import uuid
2+
import warnings
23
from datetime import datetime
34
from typing import Any, Dict, List, Optional
45

@@ -84,12 +85,28 @@ def apply_processors(
8485

8586
def save_to_storage(self, upload_storage: Optional[str] = None) -> None:
8687
"""Save current file into provided `upload_storage`"""
87-
metadata = self.get("metadata", {})
88-
metadata.update({"filename": self.filename, "content_type": self.content_type})
88+
extra = self.get("extra", {})
89+
extra.update({"content_type": self.content_type})
90+
91+
metadata = self.get("metadata", None)
92+
if metadata is not None:
93+
warnings.warn(
94+
'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
95+
DeprecationWarning,
96+
)
97+
extra.update({"meta_data": metadata})
98+
99+
if extra.get("meta_data", None) is None:
100+
extra["meta_data"] = {}
101+
102+
extra["meta_data"].update(
103+
{"filename": self.filename, "content_type": self.content_type}
104+
)
89105
stored_file = self.store_content(
90106
self.original_content,
91107
upload_storage,
92-
metadata=metadata,
108+
extra=extra,
109+
headers=self.get("headers", None),
93110
)
94111
self["file_id"] = stored_file.name
95112
self["upload_storage"] = upload_storage
@@ -104,13 +121,22 @@ def store_content(
104121
upload_storage: Optional[str] = None,
105122
name: Optional[str] = None,
106123
metadata: Optional[Dict[str, Any]] = None,
124+
extra: Optional[Dict[str, Any]] = None,
125+
headers: Optional[Dict[str, str]] = None,
107126
) -> StoredFile:
108127
"""Store content into provided `upload_storage`
109128
with additional `metadata`. Can be use by processors
110129
to store additional files.
111130
"""
112131
name = name or str(uuid.uuid4())
113-
stored_file = StorageManager.save_file(name, content, upload_storage, metadata)
132+
stored_file = StorageManager.save_file(
133+
name=name,
134+
content=content,
135+
upload_storage=upload_storage,
136+
metadata=metadata,
137+
extra=extra,
138+
headers=headers,
139+
)
114140
self["files"].append("%s/%s" % (upload_storage, name))
115141
return stored_file
116142

sqlalchemy_file/mutable_list.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ class MutableList(Mutable, typing.List[T]):
2424
def __init__(self, *args, **kwargs) -> None: # type: ignore
2525
super(MutableList, self).__init__(*args, **kwargs)
2626
self._removed: List[T] = []
27-
# logging.warning(('init', self._removed, args, kwargs))
2827

2928
@classmethod
3029
def coerce(cls, key: Any, value: Any) -> Any:

sqlalchemy_file/processors.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ def process(self, file: "File", upload_storage: Optional[str] = None) -> None:
114114
f"image/{self.thumbnail_format}".lower(),
115115
)
116116
ext = mimetypes.guess_extension(content_type)
117-
metadata = file.get("metadata", {})
117+
extra = file.get("extra", {})
118+
metadata = extra.get("meta_data", {})
118119
metadata.update(
119120
{
120121
"filename": file["filename"] + f".thumbnail{width}x{height}{ext}",
@@ -123,10 +124,9 @@ def process(self, file: "File", upload_storage: Optional[str] = None) -> None:
123124
"height": height,
124125
}
125126
)
127+
extra.update({"meta_data": metadata})
126128
stored_file = file.store_content(
127-
output,
128-
upload_storage,
129-
metadata=metadata,
129+
output, upload_storage, extra=extra, headers=file.get("headers", None)
130130
)
131131
file.update(
132132
{

sqlalchemy_file/storage.py

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from typing import Any, Dict, Iterator, Optional
23

34
from libcloud.storage.base import Container
@@ -70,38 +71,45 @@ def save_file(
7071
content: Iterator[bytes],
7172
upload_storage: Optional[str] = None,
7273
metadata: Optional[Dict[str, Any]] = None,
74+
extra: Optional[Dict[str, Any]] = None,
7375
headers: Optional[Dict[str, str]] = None,
7476
) -> StoredFile:
77+
if metadata is not None:
78+
warnings.warn(
79+
'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
80+
DeprecationWarning,
81+
)
82+
extra = {
83+
"meta_data": metadata,
84+
"content_type": metadata.get(
85+
"content_type", "application/octet-stream"
86+
),
87+
}
7588
"""Save file into provided `upload_storage`"""
7689
container = cls.get(upload_storage)
77-
if container.driver.name == LOCAL_STORAGE_DRIVER_NAME:
78-
obj = container.upload_object_via_stream(iterator=content, object_name=name)
79-
if metadata is not None:
80-
"""
81-
Libcloud local storage driver doesn't support metadata, so the metadata
82-
is saved in the same container with the combination of the original name
83-
and `.metadata.json` as name
84-
"""
85-
container.upload_object_via_stream(
86-
iterator=get_metadata_file_obj(metadata),
87-
object_name=f"{name}.metadata.json",
88-
)
89-
return StoredFile(obj)
90-
else:
91-
extra = {}
92-
if metadata is not None:
93-
if "content_type" in metadata:
94-
extra["content_type"] = metadata["content_type"]
95-
extra["meta_data"] = metadata
96-
return StoredFile(
97-
container.upload_object_via_stream(
98-
iterator=content, object_name=name, extra=extra, headers=headers
99-
)
90+
if (
91+
container.driver.name == LOCAL_STORAGE_DRIVER_NAME
92+
and extra is not None
93+
and extra.get("meta_data", None) is not None
94+
):
95+
"""
96+
Libcloud local storage driver doesn't support metadata, so the metadata
97+
is saved in the same container with the combination of the original name
98+
and `.metadata.json` as name
99+
"""
100+
container.upload_object_via_stream(
101+
iterator=get_metadata_file_obj(extra["meta_data"]),
102+
object_name=f"{name}.metadata.json",
103+
)
104+
return StoredFile(
105+
container.upload_object_via_stream(
106+
iterator=content, object_name=name, extra=extra, headers=headers
100107
)
108+
)
101109

102110
@classmethod
103111
def get_file(cls, path: str) -> StoredFile:
104-
"""Retrieve the file with `provided` path
112+
"""Retrieve the file with `provided` path,
105113
path is expected to be `storage_name/file_id`
106114
"""
107115
upload_storage, file_id = path.split("/")

sqlalchemy_file/types.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def __init__(
4444
processors: Optional[List[Processor]] = None,
4545
upload_type: Type[File] = File,
4646
multiple: Optional[bool] = False,
47+
extra: Optional[Dict[str, Any]] = None,
48+
headers: Optional[Dict[str, str]] = None,
4749
**kwargs: Dict[str, Any],
4850
) -> None:
4951
"""
@@ -52,8 +54,12 @@ def __init__(
5254
validators: List of validators to apply
5355
processors: List of validators to apply
5456
upload_type: File class to use, could be
55-
use to set custom File class
57+
used to set custom File class
5658
multiple: Use this to save multiple files
59+
extra: Extra attributes (driver specific)
60+
headers: Additional request headers,
61+
such as CORS headers. For example:
62+
headers = {'Access-Control-Allow-Origin': 'http://mozilla.com'}
5763
"""
5864
super().__init__(*args, **kwargs)
5965
if processors is None:
@@ -63,6 +69,8 @@ def __init__(
6369
self.upload_storage = upload_storage
6470
self.upload_type = upload_type
6571
self.multiple = multiple
72+
self.extra = extra
73+
self.headers = headers
6674
self.validators = validators
6775
self.processors = processors
6876

@@ -115,6 +123,8 @@ def __init__(
115123
processors: Optional[List[Processor]] = None,
116124
upload_type: Type[File] = File,
117125
multiple: Optional[bool] = False,
126+
extra: Optional[Dict[str, str]] = None,
127+
headers: Optional[Dict[str, str]] = None,
118128
**kwargs: Dict[str, Any],
119129
) -> None:
120130
"""
@@ -128,8 +138,9 @@ def __init__(
128138
validators: List of additional validators to apply
129139
processors: List of validators to apply
130140
upload_type: File class to use, could be
131-
use to set custom File class
141+
used to set custom File class
132142
multiple: Use this to save multiple files
143+
extra: Extra attributes (driver specific)
133144
"""
134145
if validators is None:
135146
validators = []
@@ -147,6 +158,8 @@ def __init__(
147158
processors=processors,
148159
upload_type=upload_type,
149160
multiple=multiple,
161+
extra=extra,
162+
headers=headers,
150163
**kwargs,
151164
)
152165

@@ -323,6 +336,13 @@ def prepare_file_attr(
323336
upload_storage = column_type.upload_storage or StorageManager.get_default()
324337
for value in prepared_values:
325338
if not getattr(value, "saved", False):
339+
if column_type.extra is not None and value.get("extra", None) is None:
340+
value["extra"] = column_type.extra
341+
if (
342+
column_type.headers is not None
343+
and value.get("headers", None) is None
344+
):
345+
value["headers"] = column_type.headers
326346
value.save_to_storage(upload_storage)
327347
value.apply_processors(column_type.processors, upload_storage)
328348
return changed, (

tests/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from libcloud.storage.drivers.dummy import DummyFileObject as BaseDummyFileObject
2+
3+
4+
class DummyFile(BaseDummyFileObject):
5+
"""Add size just for test purpose"""
6+
7+
def __init__(self, yield_count=5, chunk_len=10):
8+
super().__init__(yield_count, chunk_len)
9+
self.size = len(self)
10+
self.filename = "dummy-file"
11+
self.content_type = "application/octet-stream"

0 commit comments

Comments
 (0)