Spaces:
Sleeping
Sleeping
| """Async SQLAlchemy engine + session factory. | |
| Driver selection: production = `asyncpg` (Neon Postgres). Tests = `aiosqlite` | |
| (in-process, fast). The DATABASE_URL is normalized so callers can supply | |
| either a Neon connection string (with `?sslmode=require&channel_binding=require`) | |
| or `sqlite+aiosqlite:///:memory:`. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| from contextlib import asynccontextmanager | |
| from typing import AsyncIterator | |
| from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine | |
| from sqlalchemy.pool import NullPool | |
| def _normalize_url(url: str) -> str: | |
| # Neon hands out `postgresql://...` — SQLAlchemy needs the async driver explicit. | |
| if url.startswith("postgresql://"): | |
| url = url.replace("postgresql://", "postgresql+asyncpg://", 1) | |
| if url.startswith("postgres://"): | |
| url = url.replace("postgres://", "postgresql+asyncpg://", 1) | |
| # asyncpg doesn't understand libpq query flags Neon includes. Strip them | |
| # but keep them noted; ssl is requested via connect_args instead. | |
| if "+asyncpg://" in url: | |
| url = re.sub(r"[?&](sslmode|channel_binding|application_name)=[^&]*", "", url) | |
| url = re.sub(r"\?&", "?", url).rstrip("?&") | |
| return url | |
| def _connect_args(url: str) -> dict: | |
| if "+asyncpg://" in url and "neon.tech" in url: | |
| return {"ssl": True} | |
| return {} | |
| def get_database_url() -> str: | |
| url = os.environ.get("DATABASE_URL") | |
| if not url: | |
| raise RuntimeError("DATABASE_URL is not set") | |
| return _normalize_url(url) | |
| _engine = None | |
| _session_factory = None | |
| def get_engine(): | |
| global _engine | |
| if _engine is None: | |
| url = get_database_url() | |
| # SQLite (test) → NullPool: connection-per-checkout, no caching across | |
| # event loops. Production Postgres uses the default pool with pre-ping. | |
| if url.startswith("sqlite"): | |
| _engine = create_async_engine(url, poolclass=NullPool, connect_args=_connect_args(url)) | |
| else: | |
| _engine = create_async_engine( | |
| url, | |
| pool_pre_ping=True, | |
| pool_size=int(os.environ.get("DB_POOL_SIZE", "5")), | |
| max_overflow=int(os.environ.get("DB_MAX_OVERFLOW", "5")), | |
| connect_args=_connect_args(url), | |
| ) | |
| return _engine | |
| def get_session_factory() -> async_sessionmaker[AsyncSession]: | |
| global _session_factory | |
| if _session_factory is None: | |
| _session_factory = async_sessionmaker( | |
| get_engine(), | |
| expire_on_commit=False, | |
| class_=AsyncSession, | |
| ) | |
| return _session_factory | |
| async def session_scope() -> AsyncIterator[AsyncSession]: | |
| factory = get_session_factory() | |
| async with factory() as session: | |
| try: | |
| yield session | |
| await session.commit() | |
| except Exception: | |
| await session.rollback() | |
| raise | |
| def reset_engine_for_tests() -> None: | |
| """Test helper — discard cached engine so a new DATABASE_URL takes effect.""" | |
| global _engine, _session_factory | |
| _engine = None | |
| _session_factory = None | |