From 1bdcff1af53fd98d6c2dec515164f5ccfe3b7d1c Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 29 Aug 2025 22:17:22 +0300 Subject: [PATCH] chore: set up linting and CI --- .github/workflows/ci.yml | 18 ++ .pre-commit-config.yaml | 17 ++ alembic/env.py | 9 +- alembic/versions/20250617_add_book_status.py | 3 +- ...0fed_add_user_foreign_key_to_book_model.py | 28 ++-- ..._switch_to_crockford_base32_string_ids_.py | 157 +++++++++++------- ...6c874_add_title_field_to_storage_schema.py | 7 +- api/v1/schemas/book_schemas.py | 57 ++++--- core/auth.py | 11 +- core/logger.py | 3 +- database/__init__.py | 2 +- database/book_crud.py | 2 +- pyproject.toml | 40 +++-- 13 files changed, 223 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a86f424 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: pre-commit/action@v3.0.0 + with: + extra_args: --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..342d365 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.3.0 + hooks: + - id: black + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.7 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + args: [--ignore-missing-imports] + files: "^(core|config)/" diff --git a/alembic/env.py b/alembic/env.py index 9f55ea9..d6d8e0b 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,6 +1,6 @@ +import asyncio import pathlib import sys -import asyncio sys.path.append(str((pathlib.Path(__file__).parent.parent.parent).resolve())) @@ -11,7 +11,6 @@ from alembic import context from database.base import Base -from models import domain_models config = context.config @@ -55,7 +54,9 @@ def run_migrations_online() -> None: """ config_section = config.get_section(config.config_ini_section, {}) - url = config_section.get("sqlalchemy.url") or config.get_main_option("sqlalchemy.url") + url = config_section.get("sqlalchemy.url") or config.get_main_option( + "sqlalchemy.url", + ) if url and url.startswith("postgresql+asyncpg"): # Use async engine for async DB URLs @@ -67,7 +68,7 @@ async def run_async_migrations(): lambda sync_conn: context.configure( connection=sync_conn, target_metadata=target_metadata, - ) + ), ) async with connection.begin(): await connection.run_sync(context.run_migrations) diff --git a/alembic/versions/20250617_add_book_status.py b/alembic/versions/20250617_add_book_status.py index 49c852f..754aaef 100644 --- a/alembic/versions/20250617_add_book_status.py +++ b/alembic/versions/20250617_add_book_status.py @@ -5,9 +5,10 @@ Create Date: 2025-06-17 """ -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision = "20250617_add_book_status" down_revision = "983f05a87193" diff --git a/alembic/versions/6ce246100fed_add_user_foreign_key_to_book_model.py b/alembic/versions/6ce246100fed_add_user_foreign_key_to_book_model.py index 6c24117..ec98048 100644 --- a/alembic/versions/6ce246100fed_add_user_foreign_key_to_book_model.py +++ b/alembic/versions/6ce246100fed_add_user_foreign_key_to_book_model.py @@ -5,28 +5,36 @@ Create Date: 2025-06-18 23:01:09.599133 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '6ce246100fed' -down_revision: Union[str, None] = '20250617_add_book_status' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "6ce246100fed" +down_revision: str | None = "20250617_add_book_status" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column('books', sa.Column('user_id', sa.String(), nullable=False)) - op.create_foreign_key(None, 'books', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.add_column("books", sa.Column("user_id", sa.String(), nullable=False)) + op.create_foreign_key( + None, + "books", + "users", + ["user_id"], + ["id"], + ondelete="CASCADE", + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'books', type_='foreignkey') - op.drop_column('books', 'user_id') + op.drop_constraint(None, "books", type_="foreignkey") + op.drop_column("books", "user_id") # ### end Alembic commands ### diff --git a/alembic/versions/983f05a87193_switch_to_crockford_base32_string_ids_.py b/alembic/versions/983f05a87193_switch_to_crockford_base32_string_ids_.py index 4f08fb4..eb716cc 100644 --- a/alembic/versions/983f05a87193_switch_to_crockford_base32_string_ids_.py +++ b/alembic/versions/983f05a87193_switch_to_crockford_base32_string_ids_.py @@ -1,87 +1,118 @@ """Switch to Crockford Base32 string IDs for all models Revision ID: 983f05a87193 -Revises: +Revises: Create Date: 2025-06-16 22:22:13.279601 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. -revision: str = '983f05a87193' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "983f05a87193" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('books', - sa.Column('id', sa.String(), nullable=False), - sa.Column('title', sa.String(), nullable=True), - sa.Column('authors', postgresql.ARRAY(postgresql.JSON(astext_type=sa.Text())), nullable=True), - sa.Column('publisher', sa.String(), nullable=True), - sa.Column('publication_date', sa.String(), nullable=True), - sa.Column('isbn_10', sa.String(), nullable=True), - sa.Column('isbn_13', sa.String(), nullable=True), - sa.Column('language', sa.String(), nullable=True), - sa.Column('series_name', sa.String(), nullable=True), - sa.Column('series_index', sa.Float(), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('tags', postgresql.ARRAY(sa.String()), nullable=True), - sa.Column('identifiers', postgresql.ARRAY(postgresql.JSON(astext_type=sa.Text())), nullable=True), - sa.Column('covers', postgresql.ARRAY(postgresql.JSON(astext_type=sa.Text())), nullable=False), - sa.Column('format', sa.String(), nullable=True), - sa.Column('original_filename', sa.String(), nullable=True), - sa.Column('stored_filename', sa.String(), nullable=True), - sa.Column('file_hash', sa.String(), nullable=True), - sa.Column('file_path', sa.String(), nullable=True), - sa.Column('file_size_bytes', sa.Integer(), nullable=True), - sa.Column('uploaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.Column('modified_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('file_hash') + op.create_table( + "books", + sa.Column("id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=True), + sa.Column( + "authors", + postgresql.ARRAY(postgresql.JSON(astext_type=sa.Text())), + nullable=True, + ), + sa.Column("publisher", sa.String(), nullable=True), + sa.Column("publication_date", sa.String(), nullable=True), + sa.Column("isbn_10", sa.String(), nullable=True), + sa.Column("isbn_13", sa.String(), nullable=True), + sa.Column("language", sa.String(), nullable=True), + sa.Column("series_name", sa.String(), nullable=True), + sa.Column("series_index", sa.Float(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("tags", postgresql.ARRAY(sa.String()), nullable=True), + sa.Column( + "identifiers", + postgresql.ARRAY(postgresql.JSON(astext_type=sa.Text())), + nullable=True, + ), + sa.Column( + "covers", + postgresql.ARRAY(postgresql.JSON(astext_type=sa.Text())), + nullable=False, + ), + sa.Column("format", sa.String(), nullable=True), + sa.Column("original_filename", sa.String(), nullable=True), + sa.Column("stored_filename", sa.String(), nullable=True), + sa.Column("file_hash", sa.String(), nullable=True), + sa.Column("file_path", sa.String(), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=True), + sa.Column( + "uploaded_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("modified_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("file_hash"), ) - op.create_index(op.f('ix_books_id'), 'books', ['id'], unique=False) - op.create_table('users', - sa.Column('id', sa.String(), nullable=False), - sa.Column('username', sa.String(), nullable=False), - sa.Column('email', sa.String(), nullable=False), - sa.Column('password', sa.String(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('preferences', postgresql.JSON(astext_type=sa.Text()), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_index(op.f("ix_books_id"), "books", ["id"], unique=False) + op.create_table( + "users", + sa.Column("id", sa.String(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column( + "preferences", + postgresql.JSON(astext_type=sa.Text()), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) - op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) - op.create_table('storage', - sa.Column('id', sa.String(), nullable=False), - sa.Column('config', sa.JSON(), nullable=False), - sa.Column('storage_type', sa.String(), nullable=False), - sa.Column('user_id', sa.String(), nullable=False), - sa.Column('is_default', sa.Boolean(), nullable=False, comment='Indicates whether this is the primary (default) storage for the user. Only one storage entry per user can have this set to True.'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + op.create_table( + "storage", + sa.Column("id", sa.String(), nullable=False), + sa.Column("config", sa.JSON(), nullable=False), + sa.Column("storage_type", sa.String(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column( + "is_default", + sa.Boolean(), + nullable=False, + comment="Indicates whether this is the primary (default) storage for the user. Only one storage entry per user can have this set to True.", + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_storage_id'), 'storage', ['id'], unique=False) + op.create_index(op.f("ix_storage_id"), "storage", ["id"], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_storage_id'), table_name='storage') - op.drop_table('storage') - op.drop_index(op.f('ix_users_username'), table_name='users') - op.drop_index(op.f('ix_users_id'), table_name='users') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - op.drop_index(op.f('ix_books_id'), table_name='books') - op.drop_table('books') + op.drop_index(op.f("ix_storage_id"), table_name="storage") + op.drop_table("storage") + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + op.drop_index(op.f("ix_books_id"), table_name="books") + op.drop_table("books") # ### end Alembic commands ### diff --git a/alembic/versions/a9d8b686c874_add_title_field_to_storage_schema.py b/alembic/versions/a9d8b686c874_add_title_field_to_storage_schema.py index bb94e1a..b96416a 100644 --- a/alembic/versions/a9d8b686c874_add_title_field_to_storage_schema.py +++ b/alembic/versions/a9d8b686c874_add_title_field_to_storage_schema.py @@ -7,7 +7,6 @@ """ from collections.abc import Sequence -from typing import Union import sqlalchemy as sa @@ -15,9 +14,9 @@ # revision identifiers, used by Alembic. revision: str = "a9d8b686c874" -down_revision: Union[str, None] = "6ce246100fed" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = "6ce246100fed" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/api/v1/schemas/book_schemas.py b/api/v1/schemas/book_schemas.py index 5ebbfd6..7004aaf 100644 --- a/api/v1/schemas/book_schemas.py +++ b/api/v1/schemas/book_schemas.py @@ -1,12 +1,11 @@ from datetime import datetime -from typing import Optional from pydantic import BaseModel, HttpUrl class AuthorSchema(BaseModel): name: str - role: Optional[str] = None + role: str | None = None class IdentifierSchema(BaseModel): @@ -15,19 +14,19 @@ class IdentifierSchema(BaseModel): class BookBase(BaseModel): - title: Optional[str] = None - authors: Optional[list[AuthorSchema]] = None - publisher: Optional[str] = None - publication_date: Optional[str] = None - isbn_10: Optional[str] = None - isbn_13: Optional[str] = None - language: Optional[str] = None - series_name: Optional[str] = None - series_index: Optional[float] = None - description: Optional[str] = None - tags: Optional[list[str]] = [] - identifiers: Optional[list[IdentifierSchema]] = [] - format: Optional[str] = None + title: str | None = None + authors: list[AuthorSchema] | None = None + publisher: str | None = None + publication_date: str | None = None + isbn_10: str | None = None + isbn_13: str | None = None + language: str | None = None + series_name: str | None = None + series_index: float | None = None + description: str | None = None + tags: list[str] | None = [] + identifiers: list[IdentifierSchema] | None = [] + format: str | None = None class BookCreate(BookBase): @@ -36,12 +35,12 @@ class BookCreate(BookBase): class BookUpdate(BookBase): status: str - covers: Optional[list[dict[str, str]]] = None - original_filename: Optional[str] = None - stored_filename: Optional[str] = None - file_path: Optional[str] = None - file_hash: Optional[str] = None - file_size_bytes: Optional[int] = None + covers: list[dict[str, str]] | None = None + original_filename: str | None = None + stored_filename: str | None = None + file_path: str | None = None + file_hash: str | None = None + file_size_bytes: int | None = None class BookUploadQueued(BaseModel): @@ -52,17 +51,17 @@ class BookUploadQueued(BaseModel): class BookDisplay(BookBase): id: str - file_hash: Optional[str] = None - file_path: Optional[str] = None - file_size_bytes: Optional[int] = None - download_url: Optional[HttpUrl] = None + file_hash: str | None = None + file_path: str | None = None + file_size_bytes: int | None = None + download_url: HttpUrl | None = None covers: list[dict[str, str]] = [] - original_filename: Optional[str] = None - stored_filename: Optional[str] = None + original_filename: str | None = None + stored_filename: str | None = None uploaded_at: datetime - modified_at: Optional[datetime] = None + modified_at: datetime | None = None status: str - processing_error: Optional[str] = None + processing_error: str | None = None class Config: populate_by_name = True diff --git a/core/auth.py b/core/auth.py index 3d10258..5dd42a2 100644 --- a/core/auth.py +++ b/core/auth.py @@ -1,5 +1,4 @@ -from datetime import datetime, timedelta, timezone -from typing import Optional +from datetime import UTC, datetime, timedelta from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer @@ -36,10 +35,10 @@ def get_password_hash(password): return password_context.hash(password) -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): +def create_access_token(data: dict, expires_delta: timedelta | None = None): to_encode = data.copy() - expire = datetime.now(timezone.utc) + ( + expire = datetime.now(UTC) + ( expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) @@ -48,7 +47,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): return encoded_jwt -async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]: +async def get_user_by_username(db: AsyncSession, username: str) -> User | None: result = await db.execute(select(User).where(User.username == username)) return result.scalars().first() @@ -57,7 +56,7 @@ async def authenticate_user( db: AsyncSession, username: str, password: str, -) -> Optional[User]: +) -> User | None: user = await get_user_by_username(db, username) if not user or not verify_password(password, user.password): diff --git a/core/logger.py b/core/logger.py index 47de86c..8f89176 100644 --- a/core/logger.py +++ b/core/logger.py @@ -1,8 +1,7 @@ import logging -from typing import Optional -def get_logger(name: Optional[str] = None) -> logging.Logger: +def get_logger(name: str | None = None) -> logging.Logger: logger = logging.getLogger(name or "shelf") if not logger.hasHandlers(): diff --git a/database/__init__.py b/database/__init__.py index efee7fb..9e4059e 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -1,4 +1,4 @@ -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from core.config import settings diff --git a/database/book_crud.py b/database/book_crud.py index 5e9fe0d..fbc3ba8 100644 --- a/database/book_crud.py +++ b/database/book_crud.py @@ -50,7 +50,7 @@ async def get_all_books( result = await db.execute(query.offset(skip).limit(limit)) books = result.scalars().all() count = await db.scalar( - select(func.count()).select_from(Book).where(Book.user_id == user_id) + select(func.count()).select_from(Book).where(Book.user_id == user_id), ) return books, count diff --git a/pyproject.toml b/pyproject.toml index 765a40b..9ddbc5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ name = "shelf" version = "0.1.0" description = "A FastAPI application for uploading, processing, and managing book files." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" authors = [ {name = "Karolis Strazdas", email = "karolis.strazdas@protonmail.com"}, ] @@ -18,8 +18,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Framework :: FastAPI", @@ -61,6 +59,10 @@ test = [ ] dev = [ "ruff", + "black", + "isort", + "mypy", + "bandit", "pre-commit", "coverage", ] @@ -79,7 +81,7 @@ asyncio_mode = "auto" [tool.ruff] line-length = 88 indent-width = 4 -target-version = "py39" +target-version = "py311" [tool.ruff.lint] select = [ @@ -112,17 +114,18 @@ select = [ "RUF", ] ignore = [ - "E501", - "B008", - "S101", - "ARG001", - "ARG002", + "E501", + "B008", + "S101", + "ARG001", + "ARG002", + "C901", ] fixable = ["ALL"] unfixable = [] [tool.ruff.lint.isort] -known-first-party = ["api", "core", "db", "models", "parsers", "services"] +known-first-party = ["api", "core", "database", "models", "parsers", "services", "storage"] force-sort-within-sections = true [tool.ruff.format] @@ -130,3 +133,20 @@ quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" + +[tool.black] +line-length = 88 +target-version = ["py311"] + +[tool.isort] +profile = "black" +line_length = 88 +known_first_party = ["api", "core", "config", "database", "models", "parsers", "services", "storage"] + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true +disallow_untyped_defs = false +no_implicit_optional = false +warn_redundant_casts = false +warn_unused_ignores = false