From 5c561a04490ff91804d2994e346ded17328a824a Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Sat, 16 Dec 2023 11:26:26 +0100 Subject: [PATCH 1/2] Add peewee integration --- fastapi_storages/integrations/peewee.py | 105 ++++++++++++++++++ pyproject.toml | 2 + .../test_integrations/test_peewee/__init__.py | 0 .../test_peewee/test_file.py | 57 ++++++++++ .../test_peewee/test_image.py | 69 ++++++++++++ .../test_sqlalchemy/test_file.py | 12 +- .../test_sqlalchemy/test_image.py | 12 +- tests/test_integrations/utils.py | 11 ++ 8 files changed, 246 insertions(+), 22 deletions(-) create mode 100644 fastapi_storages/integrations/peewee.py create mode 100644 tests/test_integrations/test_peewee/__init__.py create mode 100644 tests/test_integrations/test_peewee/test_file.py create mode 100644 tests/test_integrations/test_peewee/test_image.py create mode 100644 tests/test_integrations/utils.py diff --git a/fastapi_storages/integrations/peewee.py b/fastapi_storages/integrations/peewee.py new file mode 100644 index 0000000..fb70e89 --- /dev/null +++ b/fastapi_storages/integrations/peewee.py @@ -0,0 +1,105 @@ +from typing import Any, Optional + +from peewee import CharField + +try: + from PIL import Image, UnidentifiedImageError + + PIL = True +except ImportError: # pragma: no cover + PIL = False + +from fastapi_storages.base import BaseStorage, StorageFile, StorageImage +from fastapi_storages.exceptions import ValidationException + + +class FileType(CharField): + """ + File type to be used with Storage classes. Stores the file name in the column. + + ???+ usage + ```python + from fastapi_storages import FileSystemStorage + from fastapi_storages.integrations.peewee import FileType + + class Example(Model): + file = FileType(storage=FileSystemStorage(path="/tmp")) + ``` + """ + + def __init__(self, storage: BaseStorage, *args: Any, **kwargs: Any) -> None: + self.storage = storage + super().__init__(*args, **kwargs) + + def db_value(self, value: Any) -> Optional[str]: + if value is None: + return value + if len(value.file.read(1)) != 1: + return None + + file = StorageFile(name=value.filename, storage=self.storage) + file.write(file=value.file) + + value.file.close() + return file.name + + def python_value(self, value: Any) -> Optional[StorageFile]: + if value is None: + return value + + return StorageFile(name=value, storage=self.storage) + + +class ImageType(CharField): + """ + Image type using `PIL` package to be used with Storage classes. + Stores the image path in the column. + + ???+ usage + ```python + from fastapi_storages import FileSystemStorage + from fastapi_storages.integrations.peewee import ImageType + + class Example(Model): + image = ImageType(storage=FileSystemStorage(path="/tmp")) + ``` + """ + + def __init__(self, storage: BaseStorage, *args: Any, **kwargs: Any) -> None: + assert PIL is True, "'Pillow' package is required." + + self.storage = storage + super().__init__(*args, **kwargs) + + def db_value(self, value: Any) -> Optional[str]: + if value is None: + return value + if len(value.file.read(1)) != 1: + return None + + try: + image_file = Image.open(value.file) + image_file.verify() + except UnidentifiedImageError: + raise ValidationException("Invalid image file") + + image = StorageImage( + name=value.filename, + storage=self.storage, + height=image_file.height, + width=image_file.width, + ) + image.write(file=value.file) + + image_file.close() + value.file.close() + return image.name + + def python_value(self, value: Any) -> Optional[StorageImage]: + if value is None: + return value + + image = Image.open(self.storage.get_path(value)) + return StorageImage( + name=value, storage=self.storage, height=image.height, width=image.width + ) diff --git a/pyproject.toml b/pyproject.toml index ba20fbd..095ddb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dynamic = ["version"] full = [ "Pillow~=9.4", "sqlalchemy>=1.4", + "peewee>=3", ] [project.urls] @@ -64,6 +65,7 @@ dependencies = [ "coverage==6.5.0", "moto==4.1.2", "mypy==0.982", + "peewee>=3", "Pillow==9.4.0", "pytest==7.2.0", "ruff==0.0.237", diff --git a/tests/test_integrations/test_peewee/__init__.py b/tests/test_integrations/test_peewee/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_integrations/test_peewee/test_file.py b/tests/test_integrations/test_peewee/test_file.py new file mode 100644 index 0000000..32957c2 --- /dev/null +++ b/tests/test_integrations/test_peewee/test_file.py @@ -0,0 +1,57 @@ +import io +from pathlib import Path + +import pytest +from peewee import AutoField, Model, SqliteDatabase + +from fastapi_storages import FileSystemStorage +from fastapi_storages.integrations.peewee import FileType +from tests.engine import database_uri +from tests.test_integrations.utils import UploadFile + +db = SqliteDatabase(database_uri) + + +class Model(Model): + id = AutoField(primary_key=True) + file = FileType(storage=FileSystemStorage(path="/tmp"), null=True) + + class Meta: + database = db + + +@pytest.fixture(autouse=True) +def prepare_database(): + db.create_tables([Model]) + yield + db.drop_tables([Model]) + + +def test_valid_file(tmp_path: Path) -> None: + Model.file.storage = FileSystemStorage(path=str(tmp_path)) + + input_file = tmp_path / "input.txt" + input_file.write_bytes(b"123") + + upload_file = UploadFile(file=input_file.open("rb"), filename="example.txt") + Model.create(file=upload_file) + model = Model.get() + + assert model.file.name == "example.txt" + assert model.file.size == 3 + assert model.file.path == str(tmp_path / "example.txt") + + +def test_nullable_file() -> None: + model = Model(file=None) + model.save() + + assert model.file is None + + +def test_clear_empty_file() -> None: + upload_file = UploadFile(file=io.BytesIO(b""), filename="") + Model.create(file=upload_file) + + model = Model.get() + assert model.file is None diff --git a/tests/test_integrations/test_peewee/test_image.py b/tests/test_integrations/test_peewee/test_image.py new file mode 100644 index 0000000..d76980e --- /dev/null +++ b/tests/test_integrations/test_peewee/test_image.py @@ -0,0 +1,69 @@ +import io +from pathlib import Path + +import pytest +from peewee import AutoField, Model, SqliteDatabase +from PIL import Image + +from fastapi_storages import FileSystemStorage +from fastapi_storages.exceptions import ValidationException +from fastapi_storages.integrations.peewee import ImageType +from tests.engine import database_uri +from tests.test_integrations.utils import UploadFile + +db = SqliteDatabase(database_uri) + + +class Model(Model): + id = AutoField(primary_key=True) + image = ImageType(storage=FileSystemStorage(path="/tmp"), null=True) + + class Meta: + database = db + + +@pytest.fixture(autouse=True) +def prepare_database(): + db.create_tables([Model]) + yield + db.drop_tables([Model]) + + +def test_valid_image(tmp_path: Path) -> None: + Model.image.storage = FileSystemStorage(path=str(tmp_path)) + + input_file = tmp_path / "input.png" + image = Image.new("RGB", (800, 1280), (255, 255, 255)) + image.save(input_file, "PNG") + + upload_file = UploadFile(file=input_file.open("rb"), filename="image.png") + Model.create(image=upload_file) + model = Model.get() + + assert model.image.name == "image.png" + assert model.image.size == 5847 + assert model.image.path == str(tmp_path / "image.png") + + +def test_invalid_image(tmp_path: Path) -> None: + input_file = tmp_path / "image.png" + input_file.write_bytes(b"123") + upload_file = UploadFile(file=input_file.open("rb"), filename="image.png") + + with pytest.raises(ValidationException): + Model.create(image=upload_file) + + +def test_nullable_image() -> None: + Model.create(image=None) + model = Model.get() + + assert model.image is None + + +def test_clear_empty_image() -> None: + upload_file = UploadFile(file=io.BytesIO(b""), filename="") + Model.create(image=upload_file) + model = Model.get() + + assert model.image is None diff --git a/tests/test_integrations/test_sqlalchemy/test_file.py b/tests/test_integrations/test_sqlalchemy/test_file.py index b9b5bf3..9274e86 100644 --- a/tests/test_integrations/test_sqlalchemy/test_file.py +++ b/tests/test_integrations/test_sqlalchemy/test_file.py @@ -1,6 +1,5 @@ import io from pathlib import Path -from typing import BinaryIO import pytest from sqlalchemy import Column, Integer, create_engine @@ -9,21 +8,12 @@ from fastapi_storages import FileSystemStorage from fastapi_storages.integrations.sqlalchemy import FileType from tests.engine import database_uri +from tests.test_integrations.utils import UploadFile Base = declarative_base() engine = create_engine(database_uri) -class UploadFile: - """ - Dummy UploadFile like the one in Starlette. - """ - - def __init__(self, file: BinaryIO, filename: str) -> None: - self.file = file - self.filename = filename - - class Model(Base): __tablename__ = "model" diff --git a/tests/test_integrations/test_sqlalchemy/test_image.py b/tests/test_integrations/test_sqlalchemy/test_image.py index fa6e97b..e5e082f 100644 --- a/tests/test_integrations/test_sqlalchemy/test_image.py +++ b/tests/test_integrations/test_sqlalchemy/test_image.py @@ -1,6 +1,5 @@ import io from pathlib import Path -from typing import BinaryIO import pytest from PIL import Image @@ -11,21 +10,12 @@ from fastapi_storages import FileSystemStorage from fastapi_storages.integrations.sqlalchemy import ImageType from tests.engine import database_uri +from tests.test_integrations.utils import UploadFile Base = declarative_base() engine = create_engine(database_uri) -class UploadFile: - """ - Dummy UploadFile like the one in Starlette. - """ - - def __init__(self, file: BinaryIO, filename: str) -> None: - self.file = file - self.filename = filename - - class Model(Base): __tablename__ = "model" diff --git a/tests/test_integrations/utils.py b/tests/test_integrations/utils.py new file mode 100644 index 0000000..4518758 --- /dev/null +++ b/tests/test_integrations/utils.py @@ -0,0 +1,11 @@ +from typing import BinaryIO + + +class UploadFile: + """ + Dummy UploadFile like the one in Starlette. + """ + + def __init__(self, file: BinaryIO, filename: str) -> None: + self.file = file + self.filename = filename From 5789ac6044a4a24234e9c75575557dfe7fda7e72 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Sat, 16 Dec 2023 11:34:47 +0100 Subject: [PATCH 2/2] update gh action --- .github/workflows/tests.yml | 2 +- tests/engine.py | 1 + tests/test_integrations/test_peewee/test_file.py | 4 ++-- tests/test_integrations/test_peewee/test_image.py | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7febb28..59cad5e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,7 +31,7 @@ jobs: hatch run docs:build - name: "Run tests with SQLite" env: - TEST_DATABASE_URI: "sqlite:///test.db" + TEST_DATABASE_URI: "sqlite://" run: hatch run test - name: "Enforce coverage" run: hatch run cov diff --git a/tests/engine.py b/tests/engine.py index 38db1c4..2360559 100644 --- a/tests/engine.py +++ b/tests/engine.py @@ -1,3 +1,4 @@ import os +database_name = os.environ.get("TEST_DATABASE_NAME", "test.sqlite") database_uri = os.environ.get("TEST_DATABASE_URI", "sqlite://") diff --git a/tests/test_integrations/test_peewee/test_file.py b/tests/test_integrations/test_peewee/test_file.py index 32957c2..c0aaeea 100644 --- a/tests/test_integrations/test_peewee/test_file.py +++ b/tests/test_integrations/test_peewee/test_file.py @@ -6,10 +6,10 @@ from fastapi_storages import FileSystemStorage from fastapi_storages.integrations.peewee import FileType -from tests.engine import database_uri +from tests.engine import database_name from tests.test_integrations.utils import UploadFile -db = SqliteDatabase(database_uri) +db = SqliteDatabase(database_name) class Model(Model): diff --git a/tests/test_integrations/test_peewee/test_image.py b/tests/test_integrations/test_peewee/test_image.py index d76980e..5bb88bc 100644 --- a/tests/test_integrations/test_peewee/test_image.py +++ b/tests/test_integrations/test_peewee/test_image.py @@ -8,10 +8,10 @@ from fastapi_storages import FileSystemStorage from fastapi_storages.exceptions import ValidationException from fastapi_storages.integrations.peewee import ImageType -from tests.engine import database_uri +from tests.engine import database_name from tests.test_integrations.utils import UploadFile -db = SqliteDatabase(database_uri) +db = SqliteDatabase(database_name) class Model(Model):