From 738bcaa39b394fd1b3bd5e37e41081c024527f18 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 13 Mar 2026 05:36:28 -0500 Subject: [PATCH] feat: add FastAPI lifespan hooks for browser pre-warm and clean shutdown (#90) Closes #90 Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 19 ++++++++++++++++--- app/routers_v2/players.py | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index 64cbfc2..83c0494 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import logging import os +from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI, Request @@ -16,8 +17,9 @@ logging.basicConfig( # from fastapi.staticfiles import StaticFiles # from fastapi.templating import Jinja2Templates -from .db_engine import db -from .routers_v2 import ( +from .db_engine import db # noqa: E402 +from .routers_v2.players import get_browser, shutdown_browser # noqa: E402 +from .routers_v2 import ( # noqa: E402 current, awards, teams, @@ -51,8 +53,17 @@ from .routers_v2 import ( scout_claims, ) + +@asynccontextmanager +async def lifespan(app: FastAPI): + await get_browser() + yield + await shutdown_browser() + + app = FastAPI( # root_path='/api', + lifespan=lifespan, responses={404: {"description": "Not found"}}, docs_url="/api/docs", redoc_url="/api/redoc", @@ -114,4 +125,6 @@ async def get_docs(req: Request): @app.get("/api/openapi.json", include_in_schema=False) async def openapi(): - return get_openapi(title="Paper Dynasty API", version=f"0.1.1", routes=app.routes) + return get_openapi( + title="Paper Dynasty API", version=f"0.1.1", routes=app.routes # noqa: F541 + ) diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 25a8363..403910a 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -9,7 +9,7 @@ from typing import Optional, List, Literal import logging import pydantic from pandas import DataFrame -from playwright.async_api import async_playwright +from playwright.async_api import async_playwright, Browser from ..card_creation import get_batter_card_data, get_pitcher_card_data from ..db_engine import ( @@ -31,6 +31,28 @@ from ..db_engine import ( from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token +_playwright = None +_browser = None + + +async def get_browser() -> Browser: + global _playwright, _browser + if _browser is None or not _browser.is_connected(): + _playwright = await async_playwright().start() + _browser = await _playwright.chromium.launch() + return _browser + + +async def shutdown_browser(): + global _playwright, _browser + if _browser is not None: + await _browser.close() + _browser = None + if _playwright is not None: + await _playwright.stop() + _playwright = None + + # Franchise normalization: Convert city+team names to city-agnostic team names # This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics') FRANCHISE_NORMALIZE = { @@ -144,7 +166,9 @@ async def get_players( ): all_players = Player.select() if all_players.count() == 0: - raise HTTPException(status_code=404, detail=f"There are no players to filter") + raise HTTPException( + status_code=404, detail=f"There are no players to filter" # noqa: F541 + ) if name is not None: all_players = all_players.where(fn.Lower(Player.p_name) == name.lower()) @@ -674,7 +698,7 @@ async def get_batter_card( ) headers = {"Cache-Control": "public, max-age=86400"} - filename = ( + filename = ( # noqa: F841 f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}" ) if ( @@ -799,16 +823,17 @@ async def get_batter_card( logging.debug(f"body:\n{html_response.body.decode('UTF-8')}") file_path = f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" - async with async_playwright() as p: - browser = await p.chromium.launch() - page = await browser.new_page() + browser = await get_browser() + page = await browser.new_page() + try: await page.set_content(html_response.body.decode("UTF-8")) await page.screenshot( path=file_path, type="png", clip={"x": 0.0, "y": 0, "width": 1200, "height": 600}, ) - await browser.close() + finally: + await page.close() # hti = Html2Image( # browser='chrome',