From 9bbc57acc497f04c8271faffb1adc64f59fe1062 Mon Sep 17 00:00:00 2001 From: jowilf Date: Tue, 30 Aug 2022 22:21:03 +0100 Subject: [PATCH] Improved docs. Add section `Upload File` into **Tutorial / Using files in models** --- docs/tutorial/using-files-in-models.md | 100 +++++++++++++++++- docs_src/__init__.py | 0 docs_src/tutorial/__init__.py | 0 docs_src/tutorial/quick-start/__init__.py | 0 docs_src/tutorial/storage-manager/__init__.py | 0 .../008_file_information.py | 52 +++++++++ .../using-files-in-models/__init__.py | 0 sqlalchemy_file/file.py | 7 +- sqlalchemy_file/processors.py | 14 ++- tests/test_result_value.py | 28 +++++ 10 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 docs_src/__init__.py create mode 100644 docs_src/tutorial/__init__.py create mode 100644 docs_src/tutorial/quick-start/__init__.py create mode 100644 docs_src/tutorial/storage-manager/__init__.py create mode 100644 docs_src/tutorial/using-files-in-models/008_file_information.py create mode 100644 docs_src/tutorial/using-files-in-models/__init__.py diff --git a/docs/tutorial/using-files-in-models.md b/docs/tutorial/using-files-in-models.md index ceb0018..ba880f6 100644 --- a/docs/tutorial/using-files-in-models.md +++ b/docs/tutorial/using-files-in-models.md @@ -52,12 +52,104 @@ uploaded file is a valid image. title = Column(String(100), unique=True) cover = Column(ImageField(thumbnail_size=(128, 128))) ``` -## Uploaded Files Information +## Upload File + +Let's say you defined your model like this +```python +class Attachment(Base): + __tablename__ = "attachment" + + id = Column(Integer, autoincrement=True, primary_key=True) + name = Column(String(50), unique=True) + content = Column(FileField) +``` +and configure your storage like this +```python +container = LocalStorageDriver("/tmp/storage").get_container("attachment") +StorageManager.add_storage("default", container) +``` + +### Save file object + Whenever a supported object is assigned to a [FileField][sqlalchemy_file.types.FileField] or [ImageField][sqlalchemy_file.types.ImageField] it will be converted to a [File][sqlalchemy_file.file.File] object. +```python +with Session(engine) as session: + session.add(Attachment(name="attachment1", content=open("./example.txt", "rb"))) + session.add(Attachment(name="attachment2", content=b"Hello world")) + session.add(Attachment(name="attachment3", content="Hello world")) + file = File(content="Hello World", filename="hello.txt", content_type="text/plain") + session.add(Attachment(name="attachment4", content=file)) + session.commit() +``` +The file itself will be uploaded to your configured storage, and only the [File][sqlalchemy_file.file.File] +information will be stored on the database as JSON. -This is the same object you will get back when reloading the models from database and apart from the file itself which is accessible -through the `.file` property, it provides additional attributes described into the [File][sqlalchemy_file.file.File] documentation itself. +### Retrieve file object + +This is the same [File][sqlalchemy_file.file.File] object you will get back when reloading the models from database and the file itself is accessible +through the `.file` property. + +!!! note + Check the [File][sqlalchemy_file.file.File] documentation for all default attributes save into the database. + +```python +with Session(engine) as session: + attachment = session.execute( + select(Attachment).where(Attachment.name == "attachment3") + ).scalar_one() + assert attachment.content.saved # saved is True for saved file + assert attachment.content.file.read() == b"Hello world" # access file content + assert attachment.content["filename"] is not None # `unnamed` when no filename are provided + assert attachment.content["file_id"] is not None # uuid v4 + assert attachment.content["upload_storage"] == "default" + assert attachment.content["content_type"] is not None + assert attachment.content["uploaded_at"] is not None +``` + +### Save additional information + +It's important to note that [File][sqlalchemy_file.file.File] object inherit from python `dict`. +Therefore, you can add additional information to your file object like a dict object. Just make sure to not use +the default attributes used by [File][sqlalchemy_file.file.File] object internally. + +!!! Example + ```python + content = File(open("./example.txt", "rb"),custom_key1="custom_value1", custom_key2="custom_value2") + content["custom_key3"] = "custom_value3" + attachment = Attachment(name="Dummy", content=content) + + session.add(attachment) + session.commit() + session.refresh(attachment) + + assert attachment.custom_key1 == "custom_value1" + assert attachment.custom_key2 == "custom_value2" + assert attachment["custom_key3"] == "custom_value3" + ``` + +!!! important + [File][sqlalchemy_file.file.File] provides also attribute style access. + You can access your keys as attributes. + +### Metadata + +*SQLAlchemy-file* store the uploaded file with some metadata. Only `filename` and `content_type` are sent by default, +. You can complete with `metadata` key inside your [File][sqlalchemy_file.file.File] object. + +!!! Example + ```py hl_lines="2" + with Session(engine) as session: + content = File(DummyFile(), metadata={"key1": "val1", "key2": "val2"}) + attachment = Attachment(name="Additional metadata", content=content) + session.add(attachment) + session.commit() + attachment = session.execute( + select(Attachment).where(Attachment.name == "Additional metadata") + ).scalar_one() + assert attachment.content.file.object.meta_data["key1"] == "val1" + assert attachment.content.file.object.meta_data["key2"] == "val2" + ``` ## Uploading on a Specific Storage @@ -119,7 +211,7 @@ Validators can add additional properties to the file object. For example the file object. **SQLAlchemy-file** has built-in validators to get started, but you can create your own validator -by extending [ValidationError][sqlalchemy_file.exceptions.ValidationError] base class. +by extending [Validator][sqlalchemy_file.validators.Validator] base class. Built-in validators: diff --git a/docs_src/__init__.py b/docs_src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/tutorial/__init__.py b/docs_src/tutorial/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/tutorial/quick-start/__init__.py b/docs_src/tutorial/quick-start/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/tutorial/storage-manager/__init__.py b/docs_src/tutorial/storage-manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/tutorial/using-files-in-models/008_file_information.py b/docs_src/tutorial/using-files-in-models/008_file_information.py new file mode 100644 index 0000000..d356493 --- /dev/null +++ b/docs_src/tutorial/using-files-in-models/008_file_information.py @@ -0,0 +1,52 @@ +import os + +from libcloud.storage.drivers.local import LocalStorageDriver +from sqlalchemy import Column, Integer, String, create_engine, select +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Session +from sqlalchemy_file import File, FileField +from sqlalchemy_file.storage import StorageManager + +Base = declarative_base() + + +# Define your model +class Attachment(Base): + __tablename__ = "attachment" + + id = Column(Integer, autoincrement=True, primary_key=True) + name = Column(String(50), unique=True) + content = Column(FileField) + + +# Configure Storage +os.makedirs("/tmp/storage/attachment", 0o777, exist_ok=True) +container = LocalStorageDriver("/tmp/storage").get_container("attachment") +StorageManager.add_storage("default", container) + +# Save your model +engine = create_engine( + "sqlite:///example.db", connect_args={"check_same_thread": False} +) +Base.metadata.create_all(engine) + +with Session(engine) as session: + session.add(Attachment(name="attachment1", content=open("./example.txt", "rb"))) + session.add(Attachment(name="attachment2", content=b"Hello world")) + session.add(Attachment(name="attachment3", content="Hello world")) + file = File(content="Hello World", filename="hello.txt", content_type="text/plain") + session.add(Attachment(name="attachment4", content=file)) + session.commit() + + attachment = session.execute( + select(Attachment).where(Attachment.name == "attachment3") + ).scalar_one() + assert attachment.content.saved # saved is True for saved file + assert attachment.content.file.read() == b"Hello world" # access file content + assert ( + attachment.content["filename"] is not None + ) # `unnamed` when no filename are provided + assert attachment.content["file_id"] is not None # uuid v4 + assert attachment.content["upload_storage"] == "default" + assert attachment.content["content_type"] is not None + assert attachment.content["uploaded_at"] is not None diff --git a/docs_src/tutorial/using-files-in-models/__init__.py b/docs_src/tutorial/using-files-in-models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sqlalchemy_file/file.py b/sqlalchemy_file/file.py index 5d0402c..ee453c3 100644 --- a/sqlalchemy_file/file.py +++ b/sqlalchemy_file/file.py @@ -43,8 +43,9 @@ def __init__( content: Any, filename: Optional[str] = None, content_type: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> None: - super().__init__() + super().__init__(**kwargs) if isinstance(content, dict): object.__setattr__(self, "original_content", None) object.__setattr__(self, "saved", True) @@ -83,10 +84,12 @@ def apply_processors( def save_to_storage(self, upload_storage: Optional[str] = None) -> None: """Save current file into provided `upload_storage`""" + metadata = self.get("metadata", {}) + metadata.update({"filename": self.filename, "content_type": self.content_type}) stored_file = self.store_content( self.original_content, upload_storage, - metadata={"filename": self.filename, "content_type": self.content_type}, + metadata=metadata, ) self["file_id"] = stored_file.name self["upload_storage"] = upload_storage diff --git a/sqlalchemy_file/processors.py b/sqlalchemy_file/processors.py index 8556eb2..672893f 100644 --- a/sqlalchemy_file/processors.py +++ b/sqlalchemy_file/processors.py @@ -114,15 +114,19 @@ def process(self, file: "File", upload_storage: Optional[str] = None) -> None: f"image/{self.thumbnail_format}".lower(), ) ext = mimetypes.guess_extension(content_type) - stored_file = file.store_content( - output, - upload_storage, - metadata={ + metadata = file.get("metadata", {}) + metadata.update( + { "filename": file["filename"] + f".thumbnail{width}x{height}{ext}", "content_type": content_type, "width": width, "height": height, - }, + } + ) + stored_file = file.store_content( + output, + upload_storage, + metadata=metadata, ) file.update( { diff --git a/tests/test_result_value.py b/tests/test_result_value.py index baf7278..91842d3 100644 --- a/tests/test_result_value.py +++ b/tests/test_result_value.py @@ -68,6 +68,34 @@ def test_single_column_is_dictlike(self) -> None: assert attachment.content.dummy_attr == "Dummy data" assert "del_attr" not in attachment.content + def test_file_custom_attributes(self) -> None: + with Session(engine) as session: + content = File( + DummyFile(), custom_key1="custom_value1", custom_key2="custom_value2" + ) + attachment = Attachment(name="Custom attributes", content=content) + session.add(attachment) + session.commit() + attachment = session.execute( + select(Attachment).where(Attachment.name == "Custom attributes") + ).scalar_one() + assert attachment.content["custom_key1"] == "custom_value1" + assert attachment.content["custom_key2"] == "custom_value2" + assert attachment.content.custom_key1 == "custom_value1" + assert attachment.content.custom_key2 == "custom_value2" + + def test_file_additional_metadata(self) -> None: + with Session(engine) as session: + content = File(DummyFile(), metadata={"key1": "val1", "key2": "val2"}) + attachment = Attachment(name="Additional metadata", content=content) + session.add(attachment) + session.commit() + attachment = session.execute( + select(Attachment).where(Attachment.name == "Additional metadata") + ).scalar_one() + assert attachment.content.file.object.meta_data["key1"] == "val1" + assert attachment.content.file.object.meta_data["key2"] == "val2" + def test_multiple_column_is_list_of_dictlike(self) -> None: with Session(engine) as session: attachment = Attachment(