From c350099a033c1cad6902042320f83f99e1cb0c55 Mon Sep 17 00:00:00 2001 From: nickatnight Date: Mon, 6 Feb 2023 13:43:40 -0800 Subject: [PATCH 1/2] feat: new interfaces --- cookiecutter.json | 2 - hooks/post_gen_project.py | 2 +- {{ cookiecutter.project_slug }}/Makefile | 2 +- .../src/api/v1/meme.py | 14 ++- .../src/core/enums.py | 3 + .../src/core/exceptions.py | 7 ++ .../src/interfaces/__init__.py | 0 .../src/interfaces/repository.py | 38 ++++++ .../migrations/versions/3577cec8a2bb_init.py | 43 ------- .../src/models/base.py | 32 ++++- .../src/models/meme.py | 9 +- .../src/repositories/__init__.py | 0 .../src/repositories/base.py | 118 ++++++++++++++++++ .../src/repositories/meme.py | 7 ++ .../src/repositories/sqlalchemy.py | 104 +++++++++++++++ .../src/schemas/meme.py | 6 +- 16 files changed, 323 insertions(+), 64 deletions(-) create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/enums.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/__init__.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py delete mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/migrations/versions/3577cec8a2bb_init.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/__init__.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py create mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py diff --git a/cookiecutter.json b/cookiecutter.json index 3317ae6..133d1f9 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -4,9 +4,7 @@ "project_slug_db": "{{ cookiecutter.project_name|lower|replace(' ', '') }}", "db_container_name": "db", - "backend_container_name": "backend", - "nginx_container_name": "nginx", "doctl_version": "1.92.0", diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index a28d7af..166676e 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -7,7 +7,7 @@ "%smodels/meme.py" % BASE_BACKEND_SRC_PATH, "%sschemas/meme.py" % BASE_BACKEND_SRC_PATH, "%sapi/v1/meme.py" % BASE_BACKEND_SRC_PATH, - "%smigrations/versions/3577cec8a2bb_init.py" % BASE_BACKEND_SRC_PATH, + "%srepository/meme.py" % BASE_BACKEND_SRC_PATH, "%sdb/init_db.py" % BASE_BACKEND_SRC_PATH, ] DEPLOYMENT_FILES = [ diff --git a/{{ cookiecutter.project_slug }}/Makefile b/{{ cookiecutter.project_slug }}/Makefile index 557fae1..a416972 100644 --- a/{{ cookiecutter.project_slug }}/Makefile +++ b/{{ cookiecutter.project_slug }}/Makefile @@ -40,7 +40,7 @@ black: docker compose exec {{ cookiecutter.backend_container_name }} black . # database -init-db: alembic-migrate +init-db: alembic-init alembic-migrate @echo "initializing database...." docker compose exec {{ cookiecutter.backend_container_name }} python3 src/db/init_db.py diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py index 4d79fd3..20a4684 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py @@ -1,9 +1,11 @@ +from typing import Optional + from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import select +from src.core.enums import SortOrder from src.db.session import get_session -from src.models.meme import Meme +from src.repository.meme import MemeRepository from src.schemas.common import IGetResponseBase from src.schemas.meme import IMemeRead @@ -20,11 +22,11 @@ async def memes( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1), + sort_field: Optional[str] = "created_at", + sort_order: Optional[str] = SortOrder.DESC, session: AsyncSession = Depends(get_session), ) -> IGetResponseBase[IMemeRead]: - result = await session.execute( - select(Meme).offset(skip).limit(limit).order_by(Meme.created_at.desc()) - ) - memes = result.scalars().all() + meme_repo = MemeRepository(session) + memes = meme_repo.all(skip=skip, limit=limit, sort_field=sort_field, sort_order=sort_order) return IGetResponseBase[IMemeRead](data=memes) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/enums.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/enums.py new file mode 100644 index 0000000..315cf7c --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/enums.py @@ -0,0 +1,3 @@ +class SortOrder: + ASC = "asc" + DESC = "desc" diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py new file mode 100644 index 0000000..6d1137e --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py @@ -0,0 +1,7 @@ + +class {{ cookiecutter.project_name|title|replace(' ', '') }}Exception: + pass + + +class ObjectNotFound({{ cookiecutter.project_name|title|replace(' ', '') }}Exception): + pass diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/__init__.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py new file mode 100644 index 0000000..5480d26 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py @@ -0,0 +1,38 @@ +from abc import ABCMeta, abstractmethod +from typing import Generic, Optional, TypeVar, Union, List + +from sqlmodel import SQLModel + + +ModelType = TypeVar("ModelType", bound=SQLModel) +CreateSchemaType = TypeVar("CreateSchemaType", bound=SQLModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=SQLModel) + + +class IRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType], metaclass=ABCMeta): + """Class representing the repository interface.""" + + @abstractmethod + async def create(self, obj_in: CreateSchemaType, **kwargs: int) -> ModelType: + """Create new entity and returns the saved instance.""" + raise NotImplementedError + + @abstractmethod + async def update(self, instance: ModelType, obj_in: Union[UpdateSchemaType, ModelType]) -> ModelType: + """Updates an entity and returns the saved instance.""" + raise NotImplementedError + + @abstractmethod + async def get(self, **kwargs) -> ModelType: + """Get and return one instance by filter.""" + raise NotImplementedError + + @abstractmethod + async def delete(self, **kwargs) -> None: + """Delete one instance by filter.""" + raise NotImplementedError + + @abstractmethod + async def all(self, skip: int = 0, limit: int = 100, sort_field: Optional[str] = None, sort_order: Optional[str] = None) -> List[ModelType]: + """Delete one instance by filter.""" + raise NotImplementedError diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/migrations/versions/3577cec8a2bb_init.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/migrations/versions/3577cec8a2bb_init.py deleted file mode 100644 index 5b7cd0f..0000000 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/migrations/versions/3577cec8a2bb_init.py +++ /dev/null @@ -1,43 +0,0 @@ -"""init - -Revision ID: 3577cec8a2bb -Revises: -Create Date: 2022-10-05 02:04:44.047062 - -""" -import sqlalchemy as sa -import sqlmodel -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "3577cec8a2bb" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "meme", - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), - sa.Column("submission_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("submission_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("submission_title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("permalink", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("author", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_meme_id"), "meme", ["id"], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_meme_id"), table_name="meme") - op.drop_table("meme") - # ### end Alembic commands ### diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/base.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/base.py index 26e23cb..bab271c 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/base.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/base.py @@ -2,25 +2,47 @@ from datetime import datetime from typing import Optional +from sqlalchemy import text +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql import expression from sqlmodel import Column, DateTime, Field, SQLModel +# https://docs.sqlalchemy.org/en/20/core/compiler.html#utc-timestamp-function +class utcnow(expression.FunctionElement): # type: ignore + type = DateTime() + inherit_cache = True + + +@compiles(utcnow, "postgresql") # type: ignore +def pg_utcnow(element, compiler, **kw) -> str: + return "TIMEZONE('utc', CURRENT_TIMESTAMP)" + + class BaseModel(SQLModel): - id: uuid_pkg.UUID = Field( - default_factory=uuid_pkg.uuid4, + id: Optional[int] = Field( + default=None, primary_key=True, index=True, + ) + ref_id: Optional[uuid_pkg.UUID] = Field( + default_factory=uuid_pkg.uuid4, + index=True, nullable=False, + sa_column_kwargs={"server_default": text("gen_random_uuid()"), "unique": True}, ) - updated_at: Optional[datetime] = Field( + created_at: Optional[datetime] = Field( sa_column=Column( DateTime(timezone=True), + server_default=utcnow(), nullable=True, ) ) - created_at: Optional[datetime] = Field( + updated_at: Optional[datetime] = Field( + default_factory=datetime.utcnow, sa_column=Column( DateTime(timezone=True), + onupdate=utcnow(), nullable=True, - ) + ), ) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/meme.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/meme.py index 8dec45b..e5468f5 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/meme.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/models/meme.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from pydantic import BaseConfig, validator +from pydantic import BaseConfig from sqlmodel import Column, DateTime, Field, SQLModel from src.models.base import BaseModel @@ -25,7 +25,8 @@ class Config(BaseConfig): } schema_extra = { "example": { - "id": "1234-43143-3134-13423", + "id": 1, + "ref_id": "1234-43143-3134-13423", "submission_id": "nny218", "submission_title": "This community is so nice. Helps me hodl.", "submission_url": "https://i.redd.it/gdv6tbamkb271.jpg", @@ -38,6 +39,4 @@ class Config(BaseConfig): class Meme(BaseModel, MemeBase, table=True): - @validator("created_at", pre=True, always=True) - def set_created_at_now(cls, v): - return v or datetime.now(timezone.utc) + pass diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/__init__.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py new file mode 100644 index 0000000..911c445 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py @@ -0,0 +1,118 @@ +import logging +from abc import ABCMeta +from typing import Generic, Optional, Type, TypeVar, Union, List +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel, select + +from src.core.enums import SortOrder +from src.core.exceptions import ObjectNotFound + + +ModelType = TypeVar("ModelType", bound=SQLModel) +CreateSchemaType = TypeVar("CreateSchemaType", bound=SQLModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=SQLModel) + +logger: logging.Logger = logging.getLogger(__name__) + + +class AbstractRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType], metaclass=ABCMeta): + """interface for database operations""" + + model: Type[ModelType] + + def __init__(self, db: AsyncSession) -> None: + self.db: AsyncSession = db + + async def insert( + self, + *, + obj_in: CreateSchemaType, + add: Optional[bool] = True, + flush: Optional[bool] = True, + commit: Optional[bool] = True, + ) -> ModelType: + db_obj = self.model.from_orm(obj_in) + + logger.info(f"Inserting new object[{db_obj}]") + + if add: + self.db.add(db_obj) + + # Navigate these with caution + if add and commit: + try: + await self.db.commit() + await self.db.refresh(db_obj) + except Exception as exc: + logger.error(exc) + await self.db.rollback() + + elif add and flush: + await self.db.flush() + + return db_obj + + async def get(self, *, ref_id: Union[UUID, str]) -> ModelType: + logger.info(f"Fetching [{self.model}] object by [{ref_id}]") + + query = select(self.model).where(getattr(self.model, "ref_id") == ref_id) + response = await self.db.execute(query) + scalar: Optional[ModelType] = response.scalar_one_or_none() + + if not scalar: + raise ObjectNotFound(f"Object with [{ref_id}] not found.") + + return scalar + + async def update( + self, + *, + obj_current: ModelType, + obj_in: Union[UpdateSchemaType, ModelType], + ) -> ModelType: + logger.info(f"Updating [{self.model}] object with [{obj_in}]") + + update_data = obj_in.dict( + exclude_unset=True + ) # This tells Pydantic to not include the values that were not sent + + for field in update_data: + setattr(obj_current, field, update_data[field]) + + self.db.add(obj_current) + await self.db.commit() + await self.db.refresh(obj_current) + + return obj_current + + async def remove(self, *, ref_id: Union[UUID, str]) -> ModelType: + query = select(self.model).where(self.model.ref_id == ref_id) + response = await self.db.execute(query) + obj = response.scalar_one() + + await self.db.delete(obj) + await self.db.commit() + return obj + + async def all( + self, + *, + skip: int = 0, + limit: int = 100, + sort_field: Optional[str] = "created_at", + sort_order: Optional[str] = SortOrder.DESC, + ) -> List[ModelType]: + columns = self.model.__table__.columns + + order_by = getattr(columns[sort_field], sort_order)() + query = ( + select(self.model) + .offset(skip) + .limit(limit) + .order_by(order_by) + ) + + response = await self.db.execute(query) + return response.scalars().all() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py new file mode 100644 index 0000000..b702c1b --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py @@ -0,0 +1,7 @@ +from src.models.meme import Meme +from src.repository.base import AbstractRepository +from src.schemas.meme import IMemeCreate, IMemeUpdate + + +class MemeRepository(AbstractRepository[Meme, IMemeCreate, IMemeUpdate]): + model = Meme diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py new file mode 100644 index 0000000..1d710dd --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py @@ -0,0 +1,104 @@ +import logging +from abc import ABCMeta +from typing import Generic, Optional, Type, TypeVar, Union, List +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel, select + +from src.core.enums import SortOrder +from src.core.exceptions import ObjectNotFound +from src.interfaces.repository import IRepository + + +ModelType = TypeVar("ModelType", bound=SQLModel) +CreateSchemaType = TypeVar("CreateSchemaType", bound=SQLModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=SQLModel) + +logger: logging.Logger = logging.getLogger(__name__) + + +class SQLAlchemyRepository(IRepository): + + def __init__(self, model: Type[ModelType], db: AsyncSession) -> None: + self.model = model + self.db: AsyncSession = db + + async def create(self, obj_in: CreateSchemaType, **kwargs: int) -> ModelType: + logger.info(f"Inserting new object[{db_obj}]") + + db_obj = self.model.from_orm(obj_in) + add = kwargs.get("add", True) + flush = kwargs.get("flush", True) + commit = kwargs.get("commit", True) + + if add: + self.db.add(db_obj) + + # Navigate these with caution + if add and commit: + try: + await self.db.commit() + await self.db.refresh(db_obj) + except Exception as exc: + logger.error(exc) + await self.db.rollback() + + elif add and flush: + await self.db.flush() + + return db_obj + + async def get(self, **kwargs: int) -> ModelType: + logger.info(f"Fetching [{self.model}] object by [{kwargs}]") + + query = select(self.model).filter_by(**kwargs) + response = await self.db.execute(query) + scalar: Optional[ModelType] = response.scalar_one_or_none() + + if not scalar: + raise ObjectNotFound(f"Object with [{kwargs}] not found.") + + return scalar + + async def update(self, obj_current: ModelType, obj_in: Union[UpdateSchemaType, ModelType]) -> ModelType: + logger.info(f"Updating [{self.model}] object with [{obj_in}]") + + update_data = obj_in.dict( + exclude_unset=True + ) # This tells Pydantic to not include the values that were not sent + + for field in update_data: + setattr(obj_current, field, update_data[field]) + + self.db.add(obj_current) + await self.db.commit() + await self.db.refresh(obj_current) + + return obj_current + + async def delete(self, **kwargs: int) -> None: + obj = self.get(**kwargs) + + await self.db.delete(obj) + await self.db.commit() + + async def all( + self, + skip: int = 0, + limit: int = 100, + sort_field: Optional[str] = None, + sort_order: Optional[str] = None, + ) -> List[ModelType]: + columns = self.model.__table__.columns + + order_by = getattr(columns[sort_field], sort_order)() + query = ( + select(self.model) + .offset(skip) + .limit(limit) + .order_by(order_by) + ) + + response = await self.db.execute(query) + return response.scalars().all() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/schemas/meme.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/schemas/meme.py index b0e49f3..0c6f20c 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/schemas/meme.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/schemas/meme.py @@ -8,4 +8,8 @@ class IMemeCreate(MemeBase): class IMemeRead(MemeBase): - id: UUID + ref_id: UUID + + +class IMemeUpdate(MemeBase): + pass From 5c550588caeae753e693aa11bc590757b8e523be Mon Sep 17 00:00:00 2001 From: nickatnight Date: Mon, 6 Feb 2023 21:46:48 -0800 Subject: [PATCH 2/2] [#9]chore: lint and clean up --- .../.pre-commit-config.yaml | 10 +- .../pyproject.toml | 14 +-- .../src/api/v1/meme.py | 17 +-- .../src/core/exceptions.py | 2 +- .../src/db/init_db.py | 3 +- .../src/interfaces/repository.py | 20 ++- .../src/repositories/base.py | 118 ------------------ .../src/repositories/meme.py | 7 -- .../src/repositories/sqlalchemy.py | 27 ++-- 9 files changed, 44 insertions(+), 174 deletions(-) delete mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py delete mode 100644 {{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py diff --git a/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml b/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml index fe32f9d..880e8f9 100644 --- a/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml +++ b/{{ cookiecutter.project_slug }}/.pre-commit-config.yaml @@ -25,8 +25,8 @@ repos: hooks: - id: isort args: ["--settings-path=./{{ cookiecutter.backend_container_name }}/pyproject.toml"] -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.982 - hooks: - - id: mypy - args: ["--config-file=./{{ cookiecutter.backend_container_name }}/pyproject.toml"] +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: v0.982 +# hooks: +# - id: mypy +# args: ["--config-file=./{{ cookiecutter.backend_container_name }}/pyproject.toml"] diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/pyproject.toml b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/pyproject.toml index f55c36b..525b4b0 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/pyproject.toml +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/pyproject.toml @@ -62,16 +62,16 @@ omit = ['*tests/*'] exclude = ["migrations/"] # --strict disallow_any_generics = true -disallow_subclassing_any = true -disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_untyped_calls = true disallow_untyped_defs = true -disallow_incomplete_defs = true -check_untyped_defs = true -disallow_untyped_decorators = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true no_implicit_optional = true -warn_redundant_casts = true +warn_redundant_casts = true warn_unused_ignores = true -warn_return_any = true +warn_return_any = true implicit_reexport = false strict_equality = true # --strict end diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py index 20a4684..d7222a4 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/v1/meme.py @@ -1,11 +1,12 @@ -from typing import Optional +from typing import Optional, List from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from src.core.enums import SortOrder from src.db.session import get_session -from src.repository.meme import MemeRepository +from src.repositories.sqlalchemy import SQLAlchemyRepository +from src.models.meme import Meme from src.schemas.common import IGetResponseBase from src.schemas.meme import IMemeRead @@ -16,17 +17,17 @@ @router.get( "/memes", response_description="List all meme instances", - response_model=IGetResponseBase[IMemeRead], + response_model=IGetResponseBase[List[IMemeRead]], tags=["memes"], ) async def memes( skip: int = Query(0, ge=0), - limit: int = Query(100, ge=1), + limit: int = Query(50, ge=1), sort_field: Optional[str] = "created_at", sort_order: Optional[str] = SortOrder.DESC, session: AsyncSession = Depends(get_session), -) -> IGetResponseBase[IMemeRead]: - meme_repo = MemeRepository(session) - memes = meme_repo.all(skip=skip, limit=limit, sort_field=sort_field, sort_order=sort_order) +) -> IGetResponseBase[List[IMemeRead]]: + meme_repo = SQLAlchemyRepository(model=Meme, db=session) + memes = await meme_repo.all(skip=skip, limit=limit, sort_field=sort_field, sort_order=sort_order) - return IGetResponseBase[IMemeRead](data=memes) + return IGetResponseBase[List[IMemeRead]](data=memes) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py index 6d1137e..a8c5ca5 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/core/exceptions.py @@ -1,5 +1,5 @@ -class {{ cookiecutter.project_name|title|replace(' ', '') }}Exception: +class {{ cookiecutter.project_name|title|replace(' ', '') }}Exception(Exception): pass diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/db/init_db.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/db/init_db.py index bee217a..8557f2f 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/db/init_db.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/db/init_db.py @@ -3,7 +3,6 @@ from src.db.session import SessionLocal from src.models.meme import Meme -from src.schemas.meme import IMemeCreate logging.basicConfig(level=logging.INFO) @@ -24,7 +23,7 @@ async def create_init_data() -> None: db_obj2 = Meme( submission_id="10t11t6", submission_title="just paid for wives bf vacation", - submission_url="https://www.reddit.com/r/dogecoin/comments/10t11t6/just_paid_for_wives_bf_vacation/", + submission_url="https://www.reddit.com/r/dogecoin/comments/10t11t6/just_paid_for_wives_bf_vacation/", # noqa permalink="/r/dogecoin/comments/10t11t6/just_paid_for_wives_bf_vacation/", author="DynamicHordeOnion", timestamp=1675473133.0, diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py index 5480d26..9180070 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/interfaces/repository.py @@ -1,38 +1,34 @@ from abc import ABCMeta, abstractmethod -from typing import Generic, Optional, TypeVar, Union, List +from typing import Generic, Optional, TypeVar, List -from sqlmodel import SQLModel +T = TypeVar("T") -ModelType = TypeVar("ModelType", bound=SQLModel) -CreateSchemaType = TypeVar("CreateSchemaType", bound=SQLModel) -UpdateSchemaType = TypeVar("UpdateSchemaType", bound=SQLModel) - -class IRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType], metaclass=ABCMeta): +class IRepository(Generic[T], metaclass=ABCMeta): """Class representing the repository interface.""" @abstractmethod - async def create(self, obj_in: CreateSchemaType, **kwargs: int) -> ModelType: + async def create(self, obj_in: T, **kwargs: int) -> T: """Create new entity and returns the saved instance.""" raise NotImplementedError @abstractmethod - async def update(self, instance: ModelType, obj_in: Union[UpdateSchemaType, ModelType]) -> ModelType: + async def update(self, instance: T, obj_in: T) -> T: """Updates an entity and returns the saved instance.""" raise NotImplementedError @abstractmethod - async def get(self, **kwargs) -> ModelType: + async def get(self, **kwargs: int) -> T: """Get and return one instance by filter.""" raise NotImplementedError @abstractmethod - async def delete(self, **kwargs) -> None: + async def delete(self, **kwargs: int) -> None: """Delete one instance by filter.""" raise NotImplementedError @abstractmethod - async def all(self, skip: int = 0, limit: int = 100, sort_field: Optional[str] = None, sort_order: Optional[str] = None) -> List[ModelType]: + async def all(self, skip: int = 0, limit: int = 50, sort_field: Optional[str] = None, sort_order: Optional[str] = None) -> List[T]: """Delete one instance by filter.""" raise NotImplementedError diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py deleted file mode 100644 index 911c445..0000000 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/base.py +++ /dev/null @@ -1,118 +0,0 @@ -import logging -from abc import ABCMeta -from typing import Generic, Optional, Type, TypeVar, Union, List -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import SQLModel, select - -from src.core.enums import SortOrder -from src.core.exceptions import ObjectNotFound - - -ModelType = TypeVar("ModelType", bound=SQLModel) -CreateSchemaType = TypeVar("CreateSchemaType", bound=SQLModel) -UpdateSchemaType = TypeVar("UpdateSchemaType", bound=SQLModel) - -logger: logging.Logger = logging.getLogger(__name__) - - -class AbstractRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType], metaclass=ABCMeta): - """interface for database operations""" - - model: Type[ModelType] - - def __init__(self, db: AsyncSession) -> None: - self.db: AsyncSession = db - - async def insert( - self, - *, - obj_in: CreateSchemaType, - add: Optional[bool] = True, - flush: Optional[bool] = True, - commit: Optional[bool] = True, - ) -> ModelType: - db_obj = self.model.from_orm(obj_in) - - logger.info(f"Inserting new object[{db_obj}]") - - if add: - self.db.add(db_obj) - - # Navigate these with caution - if add and commit: - try: - await self.db.commit() - await self.db.refresh(db_obj) - except Exception as exc: - logger.error(exc) - await self.db.rollback() - - elif add and flush: - await self.db.flush() - - return db_obj - - async def get(self, *, ref_id: Union[UUID, str]) -> ModelType: - logger.info(f"Fetching [{self.model}] object by [{ref_id}]") - - query = select(self.model).where(getattr(self.model, "ref_id") == ref_id) - response = await self.db.execute(query) - scalar: Optional[ModelType] = response.scalar_one_or_none() - - if not scalar: - raise ObjectNotFound(f"Object with [{ref_id}] not found.") - - return scalar - - async def update( - self, - *, - obj_current: ModelType, - obj_in: Union[UpdateSchemaType, ModelType], - ) -> ModelType: - logger.info(f"Updating [{self.model}] object with [{obj_in}]") - - update_data = obj_in.dict( - exclude_unset=True - ) # This tells Pydantic to not include the values that were not sent - - for field in update_data: - setattr(obj_current, field, update_data[field]) - - self.db.add(obj_current) - await self.db.commit() - await self.db.refresh(obj_current) - - return obj_current - - async def remove(self, *, ref_id: Union[UUID, str]) -> ModelType: - query = select(self.model).where(self.model.ref_id == ref_id) - response = await self.db.execute(query) - obj = response.scalar_one() - - await self.db.delete(obj) - await self.db.commit() - return obj - - async def all( - self, - *, - skip: int = 0, - limit: int = 100, - sort_field: Optional[str] = "created_at", - sort_order: Optional[str] = SortOrder.DESC, - ) -> List[ModelType]: - columns = self.model.__table__.columns - - order_by = getattr(columns[sort_field], sort_order)() - query = ( - select(self.model) - .offset(skip) - .limit(limit) - .order_by(order_by) - ) - - response = await self.db.execute(query) - return response.scalars().all() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py deleted file mode 100644 index b702c1b..0000000 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/meme.py +++ /dev/null @@ -1,7 +0,0 @@ -from src.models.meme import Meme -from src.repository.base import AbstractRepository -from src.schemas.meme import IMemeCreate, IMemeUpdate - - -class MemeRepository(AbstractRepository[Meme, IMemeCreate, IMemeUpdate]): - model = Meme diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py index 1d710dd..a5746c9 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/repositories/sqlalchemy.py @@ -1,31 +1,24 @@ import logging -from abc import ABCMeta -from typing import Generic, Optional, Type, TypeVar, Union, List -from uuid import UUID +from typing import Optional, Type, TypeVar, List from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import SQLModel, select -from src.core.enums import SortOrder from src.core.exceptions import ObjectNotFound from src.interfaces.repository import IRepository - ModelType = TypeVar("ModelType", bound=SQLModel) -CreateSchemaType = TypeVar("CreateSchemaType", bound=SQLModel) -UpdateSchemaType = TypeVar("UpdateSchemaType", bound=SQLModel) - logger: logging.Logger = logging.getLogger(__name__) -class SQLAlchemyRepository(IRepository): +class SQLAlchemyRepository(IRepository[ModelType]): def __init__(self, model: Type[ModelType], db: AsyncSession) -> None: self.model = model - self.db: AsyncSession = db + self.db = db - async def create(self, obj_in: CreateSchemaType, **kwargs: int) -> ModelType: - logger.info(f"Inserting new object[{db_obj}]") + async def create(self, obj_in: ModelType, **kwargs: int) -> ModelType: + logger.info(f"Inserting new object[{obj_in.__class__.__name__}]") db_obj = self.model.from_orm(obj_in) add = kwargs.get("add", True) @@ -61,7 +54,7 @@ async def get(self, **kwargs: int) -> ModelType: return scalar - async def update(self, obj_current: ModelType, obj_in: Union[UpdateSchemaType, ModelType]) -> ModelType: + async def update(self, obj_current: ModelType, obj_in: ModelType) -> ModelType: logger.info(f"Updating [{self.model}] object with [{obj_in}]") update_data = obj_in.dict( @@ -90,7 +83,13 @@ async def all( sort_field: Optional[str] = None, sort_order: Optional[str] = None, ) -> List[ModelType]: - columns = self.model.__table__.columns + columns = self.model.__table__.columns # type: ignore + + if not sort_field: + sort_field = "created_at" + + if not sort_order: + sort_order = "desc" order_by = getattr(columns[sort_field], sort_order)() query = (