feat(pry): production readiness pass — apify actor, async db, retry wiring, tests, observability, mypy

- Pin Dockerfile --workers 1

- Wire retry.py + circuit breakers into ultimate_scraper tiers

- Add Apify actor (apify_actor.py, Dockerfile.apify, .actor/actor.json)

- Add async SQLAlchemy support alongside sync db.py

- Add 31 HTTP integration tests (tests/test_api_integration.py + test_api_mcp.py)

- Add OTLP exporter support in observability.py

- Re-enable mypy var-annotated error code; fix annotations

- Improve CI workflow (pip cache, install -e .[dev], gitleaks, commitlint, 40% coverage gate)
This commit is contained in:
Crypto Rug Munch 2026-07-03 14:41:41 +02:00
parent 5a21133b1d
commit 345cd79bc9
21 changed files with 1979 additions and 44 deletions

106
db.py
View file

@ -62,8 +62,8 @@ import json
import logging
import os
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@ -85,6 +85,7 @@ try:
)
from sqlalchemy.engine import Engine
from sqlalchemy.exc import OperationalError, ProgrammingError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
_HAS_SA = True
@ -118,9 +119,34 @@ def _resolve_database_url() -> str:
return DEFAULT_SQLITE_URL
def _resolve_async_database_url() -> str:
"""Resolve the async database URL.
Converts ``postgresql://...`` to ``postgresql+asyncpg://...`` so the
same ``PRY_DATABASE_URL`` env var works for both sync and async uses.
SQLite stays as ``sqlite+aiosqlite://...``.
"""
url = os.getenv(DATABASE_URL_ENV)
if not url:
# SQLite async: use aiosqlite driver
return f"sqlite+aiosqlite:///{DEFAULT_DB_PATH}"
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
elif url.startswith("postgresql+psycopg2://"):
url = url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1)
elif url.startswith("sqlite://"):
url = url.replace("sqlite://", "sqlite+aiosqlite://", 1)
return url
_engine: Engine | None = None
_SessionLocal: sessionmaker[Session] | None = None
# Async engine/session for use in async FastAPI endpoints.
# These are lazily initialized alongside the sync engine.
_async_engine: Any = None # AsyncEngine (type is Any to avoid import-order issues)
_async_session_local: Any = None # async_sessionmaker[AsyncSession]
def get_engine() -> Engine:
"""Get the SQLAlchemy engine. Lazily creates one on first call."""
@ -184,6 +210,61 @@ def session_scope() -> Iterator[Session]:
s.close()
# ── Async engine / session (for async FastAPI endpoints) ──────────
def get_async_engine() -> Any:
"""Get the async SQLAlchemy engine. Lazily creates one on first call."""
global _async_engine, _async_session_local
if not _HAS_SA:
raise RuntimeError("sqlalchemy is not installed; pip install 'sqlalchemy>=2.0'")
if _async_engine is None:
url = _resolve_async_database_url()
connect_args: dict[str, Any] = {}
if url.startswith("sqlite"):
connect_args["check_same_thread"] = False
_async_engine = create_async_engine(url, connect_args=connect_args, echo=False)
_async_session_local = async_sessionmaker(
bind=_async_engine, autoflush=False, expire_on_commit=False
)
logger.info(
"db_async_engine_initialized",
extra={"url": url.split("@")[-1] if "@" in url else url},
)
return _async_engine
async def get_async_session() -> AsyncSession:
"""Get a new async Session. Caller must close it.
Prefer ``async with async_session_scope() as s:`` for automatic cleanup.
"""
if _async_session_local is None:
get_async_engine()
assert _async_session_local is not None
return _async_session_local()
@asynccontextmanager
async def async_session_scope() -> AsyncIterator[AsyncSession]:
"""Async context manager: yields an AsyncSession, commits on success,
rolls back on error.
Usage:
async with async_session_scope() as s:
result = await s.execute(select(Monitor).where(...))
"""
s = await get_async_session()
try:
yield s
await s.commit()
except Exception:
await s.rollback()
raise
finally:
await s.close()
# ── Model base ────────────────────────────────────────────────────
if _HAS_SA:
@ -827,3 +908,24 @@ def db_health() -> dict[str, Any]:
}
except SQLAlchemyError as e:
return {"available": False, "error": str(e)[:200]}
async def db_health_async() -> dict[str, Any]:
"""Async health check for the database (for async endpoints)."""
if not _HAS_SA:
return {"available": False, "reason": "sqlalchemy not installed"}
try:
engine = get_async_engine()
async with engine.connect() as conn:
await conn.execute(conn.default_schema_name or "SELECT 1")
return {
"available": True,
"url": _resolve_async_database_url().split("@")[-1]
if "@" in _resolve_async_database_url()
else _resolve_async_database_url(),
}
except SQLAlchemyError as e:
return {"available": False, "error": str(e)[:200]}
# ── end of db.py ──