Compare commits

..

1 Commits

Author SHA1 Message Date
Barcode Betty dd8ed4aec1 Fix F402: rename loop var 'table' to 'tbl' to avoid shadowing SQLAlchemy table import
CI / lint (pull_request) Successful in 5s
CI / typecheck (pull_request) Failing after 20s
CI / test (pull_request) Failing after 31s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
The for loop variable 'table' shadows the imported SQLAlchemy table()
from line 8. Ruff rule F402 fires when a loop variable shadows an import.
Rename to 'tbl'.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 18:18:19 +00:00
7 changed files with 26 additions and 96 deletions
+8 -15
View File
@@ -6,21 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from cartsnitch_api.config import settings from cartsnitch_api.config import settings
engine = create_async_engine(
def _build_engine_kwargs() -> dict: settings.database_url,
url = settings.database_url echo=False,
kwargs: dict = {"echo": False} pool_size=10,
if not url.startswith("sqlite"): max_overflow=20,
kwargs.update( pool_pre_ping=True,
pool_size=10, pool_recycle=3600,
max_overflow=20, )
pool_pre_ping=True,
pool_recycle=3600,
)
return kwargs
engine = create_async_engine(settings.database_url, **_build_engine_kwargs())
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+5 -18
View File
@@ -1,43 +1,30 @@
"""Base model and mixins for all CartSnitch ORM models.""" """Base model and mixins for all CartSnitch ORM models."""
import uuid import uuid
from datetime import UTC, datetime from datetime import datetime
from sqlalchemy import DateTime, func from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from cartsnitch_api.types import GuidType
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""Base class for all CartSnitch models.""" """Base class for all CartSnitch models."""
def _utcnow():
return datetime.now(UTC)
class TimestampMixin: class TimestampMixin:
"""Mixin providing created_at / updated_at columns.""" """Mixin providing created_at / updated_at columns."""
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True), server_default=func.now(), nullable=False
server_default=func.now(),
default=_utcnow,
nullable=False,
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
server_default=func.now(),
onupdate=_utcnow,
default=_utcnow,
nullable=False,
) )
class UUIDPrimaryKeyMixin: class UUIDPrimaryKeyMixin:
"""Mixin providing a UUID primary key using GuidType for cross-DB compatibility.""" """Mixin providing a UUID primary key."""
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
GuidType(), primary_key=True, default=uuid.uuid4 primary_key=True, default=uuid.uuid4, server_default=func.gen_random_uuid()
) )
+1 -2
View File
@@ -18,7 +18,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin, _utcnow from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from cartsnitch_api.models.price import PriceHistory from cartsnitch_api.models.price import PriceHistory
@@ -46,7 +46,6 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
ingested_at: Mapped[datetime] = mapped_column( ingested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),
default=_utcnow,
nullable=False, nullable=False,
) )
+3 -6
View File
@@ -1,7 +1,6 @@
"""User and UserStoreAccount models.""" """User and UserStoreAccount models."""
import secrets import secrets
import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -11,7 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus from cartsnitch_api.constants import AccountStatus
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.types import EncryptedJSON, GuidType from cartsnitch_api.types import EncryptedJSON
if TYPE_CHECKING: if TYPE_CHECKING:
from cartsnitch_api.models.purchase import Purchase from cartsnitch_api.models.purchase import Purchase
@@ -23,13 +22,11 @@ class User(TimestampMixin, Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(GuidType(), primary_key=True, default=uuid.uuid4) id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column( email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
Boolean, nullable=False, default=False, server_default="false"
)
image: Mapped[str | None] = mapped_column(Text, nullable=True) image: Mapped[str | None] = mapped_column(Text, nullable=True)
email_inbound_token: Mapped[str] = mapped_column( email_inbound_token: Mapped[str] = mapped_column(
String(22), String(22),
+1 -26
View File
@@ -1,10 +1,9 @@
"""Custom SQLAlchemy column types.""" """Custom SQLAlchemy column types."""
import json import json
import uuid as uuid_lib
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from sqlalchemy import String, Text from sqlalchemy import Text
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
from cartsnitch_api.config import settings from cartsnitch_api.config import settings
@@ -35,27 +34,3 @@ class EncryptedJSON(TypeDecorator):
return None return None
decrypted = _get_fernet().decrypt(value.encode()) decrypted = _get_fernet().decrypt(value.encode())
return json.loads(decrypted) return json.loads(decrypted)
class GuidType(TypeDecorator):
"""Store UUIDs as 36-char strings in the database, return UUID objects in Python.
Uses PostgreSQL UUID type when available, String(36) otherwise (SQLite).
"""
impl = String(36)
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if isinstance(value, uuid_lib.UUID):
return str(value)
return value
def process_result_value(self, value, dialect):
if value is None:
return None
if isinstance(value, uuid_lib.UUID):
return value
return uuid_lib.UUID(value)
+5 -22
View File
@@ -8,7 +8,6 @@ import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import aiosqlite
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine, event, text from sqlalchemy import create_engine, event, text
@@ -20,8 +19,6 @@ from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app from cartsnitch_api.main import create_app
from cartsnitch_api.models import Base from cartsnitch_api.models import Base
aiosqlite.register_adapter(uuid.UUID, lambda u: str(u))
TEST_JWT_SECRET = secrets.token_urlsafe(32) TEST_JWT_SECRET = secrets.token_urlsafe(32)
TEST_SERVICE_KEY = secrets.token_urlsafe(32) TEST_SERVICE_KEY = secrets.token_urlsafe(32)
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=" TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@@ -56,27 +53,17 @@ def disable_rate_limiting():
def engine(): def engine():
"""Sync in-memory SQLite engine for model unit tests. """Sync in-memory SQLite engine for model unit tests.
Strips ALL PostgreSQL-specific server_default expressions so SQLite can Strips PostgreSQL-specific server_default expressions so SQLite can
handle all column inserts without missing-function errors. handle all column inserts without missing-function errors.
""" """
eng = create_engine("sqlite:///:memory:") eng = create_engine("sqlite:///:memory:")
@event.listens_for(eng, "connect") for table in Base.metadata.tables.values():
def set_sqlite_pragma(dbapi_connection, connection_record): for col in table.columns.values():
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
for metadata_table in Base.metadata.tables.values():
for col in metadata_table.columns.values():
sd = col.server_default sd = col.server_default
if sd is not None: if sd is not None:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower() expr_str = str(sd.expression).lower()
_pg_fns = ("gen_random_uuid", "gen_random_bytes", "now()") if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
if any(pg_fn in expr_str for pg_fn in _pg_fns):
col.server_default = None col.server_default = None
Base.metadata.create_all(eng) Base.metadata.create_all(eng)
@@ -106,12 +93,8 @@ async def db_engine():
for col in table.columns.values(): for col in table.columns.values():
sd = col.server_default sd = col.server_default
if sd is not None: if sd is not None:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower() expr_str = str(sd.expression).lower()
_pg_fns = ("gen_random_uuid", "gen_random_bytes", "now()") if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
if any(pg_fn in expr_str for pg_fn in _pg_fns):
col.server_default = None col.server_default = None
async with engine.begin() as conn: async with engine.begin() as conn:
+3 -7
View File
@@ -18,16 +18,12 @@ from cartsnitch_api.models.user import User, UserStoreAccount
def engine(): def engine():
eng = create_engine("sqlite:///:memory:") eng = create_engine("sqlite:///:memory:")
for metadata_table in Base.metadata.tables.values(): for tbl in Base.metadata.tables.values():
for col in metadata_table.columns.values(): for col in tbl.columns.values():
sd = col.server_default sd = col.server_default
if sd is not None: if sd is not None:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower() expr_str = str(sd.expression).lower()
_pg_fns = ("gen_random_uuid", "gen_random_bytes", "now()") if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
if any(pg_fn in expr_str for pg_fn in _pg_fns):
col.server_default = None col.server_default = None
Base.metadata.create_all(eng) Base.metadata.create_all(eng)