diff --git a/CLAUDE.md b/CLAUDE.md index 930554fc04..aa6fd50966 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,8 @@ uv run pytest tests/unit/api/test_routers.py::test_get_specs **Both linting and formatting must pass for CI.** +**IMPORTANT: Always run `uv run ruff check && uv run ruff format ` on changed files BEFORE every commit!** + ```bash # Linting (required for CI) uv run ruff check . @@ -174,6 +176,9 @@ uv run ruff check . --fix # Formatting (required for CI) uv run ruff format . + +# Before committing - always run both on changed files: +uv run ruff check && uv run ruff format ``` ### Frontend Development diff --git a/README.md b/README.md index 613c24dd9c..3b5d92a271 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Ruff](https://github.com/MarkusNeusinger/pyplots/actions/workflows/ci-lint.yml/badge.svg?branch=main)](https://github.com/MarkusNeusinger/pyplots/actions/workflows/ci-lint.yml) [![codecov](https://codecov.io/github/MarkusNeusinger/pyplots/graph/badge.svg?token=4EGPSHH0H0)](https://codecov.io/github/MarkusNeusinger/pyplots) -> library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained. +> library-agnostic, ai-powered python plotting examples. --- diff --git a/api/main.py b/api/main.py index 01826ead78..ade3a7a01b 100644 --- a/api/main.py +++ b/api/main.py @@ -26,6 +26,7 @@ download_router, health_router, libraries_router, + og_images_router, plots_router, proxy_router, seo_router, @@ -129,6 +130,7 @@ async def add_cache_headers(request: Request, call_next): app.include_router(plots_router) app.include_router(download_router) app.include_router(seo_router) +app.include_router(og_images_router) app.include_router(proxy_router) diff --git a/api/routers/__init__.py b/api/routers/__init__.py index e30046154a..28ea7f352f 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -3,6 +3,7 @@ from api.routers.download import router as download_router from api.routers.health import router as health_router from api.routers.libraries import router as libraries_router +from api.routers.og_images import router as og_images_router from api.routers.plots import router as plots_router from api.routers.proxy import router as proxy_router from api.routers.seo import router as seo_router @@ -14,6 +15,7 @@ "download_router", "health_router", "libraries_router", + "og_images_router", "plots_router", "proxy_router", "seo_router", diff --git a/api/routers/og_images.py b/api/routers/og_images.py new file mode 100644 index 0000000000..b180acca11 --- /dev/null +++ b/api/routers/og_images.py @@ -0,0 +1,123 @@ +"""OG Image endpoints for branded social media preview images.""" + +import asyncio + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession + +from api.cache import cache_key, get_cache, set_cache +from api.dependencies import optional_db +from core.database import SpecRepository +from core.images import create_branded_og_image, create_og_collage + + +router = APIRouter(prefix="/og", tags=["og-images"]) + +# Cache TTL for generated images (1 hour) +OG_IMAGE_CACHE_TTL = 3600 + + +async def _fetch_image(url: str) -> bytes: + """Fetch an image from a URL.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + response.raise_for_status() + return response.content + + +@router.get("/{spec_id}/{library}.png") +async def get_branded_impl_image( + spec_id: str, library: str, db: AsyncSession | None = Depends(optional_db) +) -> Response: + """Get a branded OG image for an implementation. + + Returns a 1200x630 PNG with pyplots.ai header and the plot image. + """ + # Check cache first + key = cache_key("og", spec_id, library) + cached = get_cache(key) + if cached: + return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}) + + if db is None: + raise HTTPException(status_code=503, detail="Database not available") + + repo = SpecRepository(db) + spec = await repo.get_by_id(spec_id) + if not spec: + raise HTTPException(status_code=404, detail="Spec not found") + + # Find the implementation + impl = next((i for i in spec.impls if i.library_id == library), None) + if not impl or not impl.preview_url: + raise HTTPException(status_code=404, detail="Implementation not found") + + try: + # Fetch the original plot image + image_bytes = await _fetch_image(impl.preview_url) + + # Create branded image + branded_bytes = create_branded_og_image(image_bytes, spec_id=spec_id, library=library) + + # Cache the result + set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL) + + return Response( + content=branded_bytes, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"} + ) + + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch image: {e}") from e + + +@router.get("/{spec_id}.png") +async def get_spec_collage_image(spec_id: str, db: AsyncSession | None = Depends(optional_db)) -> Response: + """Get a collage OG image for a spec (showing top 6 implementations by quality). + + Returns a 1200x630 PNG with pyplots.ai branding and a 2x3 grid of implementations, + sorted by quality_score descending. + """ + # Check cache first + key = cache_key("og", spec_id, "collage") + cached = get_cache(key) + if cached: + return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}) + + if db is None: + raise HTTPException(status_code=503, detail="Database not available") + + repo = SpecRepository(db) + spec = await repo.get_by_id(spec_id) + if not spec: + raise HTTPException(status_code=404, detail="Spec not found") + + # Get implementations with preview images + impls_with_preview = [i for i in spec.impls if i.preview_url] + if not impls_with_preview: + raise HTTPException(status_code=404, detail="No implementations with previews") + + # Sort by quality_score (descending) and take top 6 for 2x3 grid + sorted_impls = sorted( + impls_with_preview, key=lambda i: i.quality_score if i.quality_score is not None else 0, reverse=True + ) + selected_impls = sorted_impls[:6] + + try: + # Fetch all images in parallel for better performance + images = list(await asyncio.gather(*[_fetch_image(impl.preview_url) for impl in selected_impls])) + labels = [f"{spec_id} · {impl.library_id}" for impl in selected_impls] + + # Create collage + collage_bytes = create_og_collage(images, labels=labels) + + # Cache the result + set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL) + + return Response( + content=collage_bytes, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"} + ) + + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch images: {e}") from e diff --git a/api/routers/seo.py b/api/routers/seo.py index 8666072fa6..d9b3df1eb4 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -37,9 +37,7 @@ """ DEFAULT_IMAGE = "https://pyplots.ai/og-image.png" -DEFAULT_DESCRIPTION = ( - "Library-agnostic, AI-powered Python plotting examples. Automatically generated, tested, and maintained." -) +DEFAULT_DESCRIPTION = "library-agnostic, ai-powered python plotting." @router.get("/sitemap.xml") @@ -115,7 +113,7 @@ async def seo_catalog(): @router.get("/seo-proxy/{spec_id}") async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(optional_db)): - """Bot-optimized spec overview page with correct og:tags.""" + """Bot-optimized spec overview page with collage og:image.""" if db is None: # Fallback when DB unavailable return HTMLResponse( @@ -132,11 +130,15 @@ async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(opti if not spec: raise HTTPException(status_code=404, detail="Spec not found") + # Use collage og:image if implementations exist, otherwise default + has_previews = any(i.preview_url for i in spec.impls) + image = f"https://api.pyplots.ai/og/{spec_id}.png" if has_previews else DEFAULT_IMAGE + return HTMLResponse( BOT_HTML_TEMPLATE.format( title=f"{html.escape(spec.title)} | pyplots.ai", description=html.escape(spec.description or DEFAULT_DESCRIPTION), - image=DEFAULT_IMAGE, + image=html.escape(image, quote=True), url=f"https://pyplots.ai/{html.escape(spec_id)}", ) ) @@ -144,7 +146,7 @@ async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(opti @router.get("/seo-proxy/{spec_id}/{library}") async def seo_spec_implementation(spec_id: str, library: str, db: AsyncSession | None = Depends(optional_db)): - """Bot-optimized spec implementation page with dynamic og:image from preview_url.""" + """Bot-optimized spec implementation page with branded og:image.""" if db is None: # Fallback when DB unavailable return HTMLResponse( @@ -163,7 +165,8 @@ async def seo_spec_implementation(spec_id: str, library: str, db: AsyncSession | # Find the implementation for this library impl = next((i for i in spec.impls if i.library_id == library), None) - image = impl.preview_url if impl and impl.preview_url else DEFAULT_IMAGE + # Use branded og:image endpoint if implementation has preview + image = f"https://api.pyplots.ai/og/{spec_id}/{library}.png" if impl and impl.preview_url else DEFAULT_IMAGE return HTMLResponse( BOT_HTML_TEMPLATE.format( diff --git a/app/index.html b/app/index.html index 766b7fad13..202fee8fb0 100644 --- a/app/index.html +++ b/app/index.html @@ -4,7 +4,7 @@ - + pyplots.ai @@ -15,14 +15,14 @@ - + - + @@ -37,7 +37,7 @@ "@type": "WebApplication", "name": "pyplots.ai", "url": "https://pyplots.ai", - "description": "Library-agnostic, AI-powered Python plotting examples. Automatically generated, tested, and maintained.", + "description": "library-agnostic, ai-powered python plotting.", "applicationCategory": "DeveloperApplication", "operatingSystem": "Any", "offers": { diff --git a/app/nginx.conf b/app/nginx.conf index a512fc5aac..d11764b1c1 100644 --- a/app/nginx.conf +++ b/app/nginx.conf @@ -1,18 +1,38 @@ # Bot detection for SEO - social media crawlers need pre-rendered meta tags +# These bots cannot execute JavaScript, so we serve them pre-rendered HTML with og:tags map $http_user_agent $is_bot { default 0; + # Social Media ~*twitterbot 1; ~*facebookexternalhit 1; ~*linkedinbot 1; + ~*pinterestbot 1; + ~*redditbot 1; + ~*tumblr 1; + ~*mastodon 1; + # Messaging Apps ~*slackbot 1; + ~*discordbot 1; ~*telegrambot 1; ~*whatsapp 1; ~*signal 1; + ~*viber 1; + ~*skypeuripreview 1; + ~*microsoft\ teams 1; + ~*snapchat 1; + # Search Engines ~*googlebot 1; ~*bingbot 1; - ~*discordbot 1; - ~*pinterestbot 1; + ~*yandexbot 1; + ~*duckduckbot 1; + ~*baiduspider 1; ~*applebot 1; + # Link Preview Services + ~*embedly 1; + ~*quora\ link\ preview 1; + ~*outbrain 1; + ~*rogerbot 1; + ~*showyoubot 1; } server { diff --git a/app/public/og-image.png b/app/public/og-image.png index 0533947b06..270c40eed2 100644 Binary files a/app/public/og-image.png and b/app/public/og-image.png differ diff --git a/core/images.py b/core/images.py index de9f1e09db..a6efc22b9b 100644 --- a/core/images.py +++ b/core/images.py @@ -3,21 +3,40 @@ This module provides reusable functions for image manipulation: - Thumbnail generation with aspect ratio preservation - PNG optimization with pngquant +- Branded og:image generation for social media +- Collage generation for spec overview pages Usage as CLI: python -m core.images thumbnail input.png output.png 400 python -m core.images process input.png output.png thumb.png + python -m core.images brand input.png output.png "scatter-basic" "matplotlib" + python -m core.images collage output.png img1.png img2.png img3.png img4.png """ +import logging +from io import BytesIO from pathlib import Path -from PIL import Image +from PIL import Image, ImageDraw, ImageFont -# Brand colors from website (kept for potential future use) +logger = logging.getLogger(__name__) + +# GCS bucket for static assets (fonts) +GCS_STATIC_BUCKET = "pyplots-static" +MONOLISA_FONT_PATH = "fonts/MonoLisaVariableNormal.ttf" +FONT_CACHE_DIR = Path("/tmp/pyplots-fonts") + +# Brand colors from website PYPLOTS_BLUE = "#3776AB" # Python blue PYPLOTS_YELLOW = "#FFD43B" # Python yellow PYPLOTS_DARK = "#1f2937" # Dark gray +PYPLOTS_BG = "#f8f9fa" # Light background + +# OG image dimensions (recommended for social media) +OG_WIDTH = 1200 +OG_HEIGHT = 630 +HEADER_HEIGHT = 80 # Optional: pngquant for better compression try: @@ -131,6 +150,444 @@ def process_plot_image( return result +# ============================================================================= +# OG Image Branding Functions +# ============================================================================= + + +def _get_monolisa_font_path() -> Path | None: + """Get path to MonoLisa font, downloading from GCS if needed. + + Returns: + Path to font file, or None if unavailable. + """ + cached_font = FONT_CACHE_DIR / "MonoLisaVariableNormal.ttf" + + # Return cached font if exists + if cached_font.exists(): + return cached_font + + # Try to download from GCS + try: + from google.cloud import storage + + FONT_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + client = storage.Client() + bucket = client.bucket(GCS_STATIC_BUCKET) + blob = bucket.blob(MONOLISA_FONT_PATH) + blob.download_to_filename(str(cached_font)) + logger.info(f"Downloaded MonoLisa font to {cached_font}") + return cached_font + except Exception as e: + logger.warning(f"Could not load MonoLisa font from GCS: {e}") + return None + + +def _get_font(size: int = 32, weight: int = 700) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Get a suitable font for text rendering. + + Tries to load MonoLisa from GCS cache, falls back to system fonts. + + Args: + size: Font size in pixels + weight: Font weight (100-900, default 700 for bold like website) + """ + # Try MonoLisa first (downloaded from GCS) + monolisa_path = _get_monolisa_font_path() + if monolisa_path: + try: + font = ImageFont.truetype(str(monolisa_path), size) + # Set variable font weight (MonoLisa supports 100-1000) + try: + font.set_variation_by_axes([weight]) + except Exception: + pass # Ignore if variation not supported + return font + except OSError: + pass + + # Fallback to system fonts + fallback_fonts = ["DejaVuSansMono-Bold.ttf", "DejaVuSansMono.ttf", "LiberationMono-Bold.ttf", "FreeMono.ttf"] + + for font_name in fallback_fonts: + try: + return ImageFont.truetype(font_name, size) + except OSError: + continue + + # Fallback to default + return ImageFont.load_default() + + +def _draw_pyplots_logo(draw: ImageDraw.ImageDraw, x: int, y: int, font_size: int = 36) -> int: + """Draw the pyplots.ai logo with proper colors. + + Args: + draw: ImageDraw instance to draw on + x: X coordinate for text start + y: Y coordinate for text baseline + font_size: Font size in pixels + + Returns: + Total width of drawn text + """ + font = _get_font(font_size) + + # Draw each part with its color + parts = [("py", PYPLOTS_BLUE), ("plots", PYPLOTS_YELLOW), (".ai", PYPLOTS_DARK)] + + current_x = x + for text, color in parts: + draw.text((current_x, y), text, fill=color, font=font) + bbox = draw.textbbox((current_x, y), text, font=font) + current_x = bbox[2] # Move to end of this text + + return current_x - x + + +def create_branded_header(width: int = OG_WIDTH, height: int = HEADER_HEIGHT) -> Image.Image: + """Create a branded header strip with pyplots.ai logo. + + Args: + width: Width of the header in pixels + height: Height of the header in pixels + + Returns: + PIL Image with the branded header + """ + header = Image.new("RGB", (width, height), PYPLOTS_BG) + draw = ImageDraw.Draw(header) + + # Center the logo + font_size = height // 2 + font = _get_font(font_size) + + # Calculate total text width for centering + test_text = "pyplots.ai" + bbox = draw.textbbox((0, 0), test_text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (width - text_width) // 2 + y = (height - text_height) // 2 - 5 # Slight adjustment for visual centering + + _draw_pyplots_logo(draw, x, y, font_size) + + return header + + +def _draw_rounded_card( + base: Image.Image, + content: Image.Image, + x: int, + y: int, + padding: int = 12, + radius: int = 16, + shadow_offset: int = 4, + shadow_blur: int = 8, +) -> None: + """Draw a rounded card with shadow containing the content image. + + Args: + base: Base image to draw on + content: Content image to put in the card + x: X position for the card + y: Y position for the card + padding: Padding inside the card + radius: Corner radius + shadow_offset: Shadow offset in pixels + shadow_blur: Shadow blur amount (not used, simplified shadow) + """ + card_width = content.width + 2 * padding + card_height = content.height + 2 * padding + + # Create shadow (simple gray rectangle offset) + shadow_color = "#d1d5db" # Light gray shadow + shadow = Image.new("RGBA", (card_width, card_height), (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + shadow_draw.rounded_rectangle([0, 0, card_width - 1, card_height - 1], radius=radius, fill=shadow_color) + base.paste(shadow, (x + shadow_offset, y + shadow_offset), shadow) + + # Create card background (white) + card = Image.new("RGBA", (card_width, card_height), (0, 0, 0, 0)) + card_draw = ImageDraw.Draw(card) + card_draw.rounded_rectangle([0, 0, card_width - 1, card_height - 1], radius=radius, fill="#ffffff") + base.paste(card, (x, y), card) + + # Paste content + base.paste(content, (x + padding, y + padding)) + + +def create_branded_og_image( + plot_image: str | Path | Image.Image | bytes, + output_path: str | Path | None = None, + spec_id: str | None = None, + library: str | None = None, +) -> Image.Image | bytes: + """Create a branded OG image by adding pyplots.ai header to a plot. + + Design matches og-image.png style: + - pyplots.ai logo at top + - Tagline "Beautiful Python plotting made easy." + - Plot in rounded paper card with shadow + - spec_id · library label below + + Args: + plot_image: Path to plot image, PIL Image, or bytes + output_path: If provided, save to this path + spec_id: Optional spec ID for subtitle + library: Optional library name for subtitle + + Returns: + PIL Image if output_path is None, otherwise bytes of PNG + """ + # Load the plot image + if isinstance(plot_image, bytes): + img = Image.open(BytesIO(plot_image)) + elif isinstance(plot_image, Image.Image): + img = plot_image + else: + img = Image.open(plot_image) + + # Convert to RGB if necessary + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + # Layout constants + top_margin = 25 + logo_height = 55 + tagline_height = 35 + bottom_margin = 45 + card_padding = 12 + label_height = 30 + + # Available space for the card + header_total = top_margin + logo_height + tagline_height + 25 # More gap after tagline + footer_total = label_height + bottom_margin + available_height = OG_HEIGHT - header_total - footer_total - 2 * card_padding + available_width = OG_WIDTH - 120 # 60px margin on each side + + # Scale plot to fit in available space + scale = min(available_width / img.width, available_height / img.height) + new_width = int(img.width * scale) + new_height = int(img.height * scale) + + # Resize plot + plot_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Create final image + final = Image.new("RGBA", (OG_WIDTH, OG_HEIGHT), PYPLOTS_BG) + draw = ImageDraw.Draw(final) + + # Draw logo (centered at top) + logo_font_size = 42 + logo_font = _get_font(logo_font_size) + logo_text = "pyplots.ai" + logo_bbox = draw.textbbox((0, 0), logo_text, font=logo_font) + logo_width = logo_bbox[2] - logo_bbox[0] + logo_x = (OG_WIDTH - logo_width) // 2 + logo_y = top_margin + _draw_pyplots_logo(draw, logo_x, logo_y, logo_font_size) + + # Draw tagline (matches website style - lowercase) + tagline = "library-agnostic, ai-powered python plotting." + tagline_font = _get_font(22, weight=400) + tagline_bbox = draw.textbbox((0, 0), tagline, font=tagline_font) + tagline_width = tagline_bbox[2] - tagline_bbox[0] + tagline_x = (OG_WIDTH - tagline_width) // 2 + tagline_y = top_margin + logo_height + 18 # More space after logo + draw.text((tagline_x, tagline_y), tagline, fill="#6b7280", font=tagline_font) + + # Draw card with plot + card_x = (OG_WIDTH - new_width - 2 * card_padding) // 2 + card_y = header_total + _draw_rounded_card(final, plot_resized, card_x, card_y, padding=card_padding) + + # Draw label below card + if spec_id or library: + label_parts = [] + if spec_id: + label_parts.append(spec_id) + if library: + label_parts.append(library) + label = " · ".join(label_parts) + + label_font = _get_font(20, weight=400) + label_bbox = draw.textbbox((0, 0), label, font=label_font) + label_width = label_bbox[2] - label_bbox[0] + label_x = (OG_WIDTH - label_width) // 2 + label_y = card_y + new_height + 2 * card_padding + 15 + draw = ImageDraw.Draw(final) # Refresh draw after card paste + draw.text((label_x, label_y), label, fill=PYPLOTS_DARK, font=label_font) + + # Convert to RGB for PNG output + final_rgb = Image.new("RGB", final.size, PYPLOTS_BG) + final_rgb.paste(final, mask=final.split()[3] if final.mode == "RGBA" else None) + + if output_path: + final_rgb.save(output_path, "PNG", optimize=True) + return final_rgb + + # Return as bytes + buffer = BytesIO() + final_rgb.save(buffer, "PNG", optimize=True) + return buffer.getvalue() + + +def create_og_collage( + images: list[str | Path | Image.Image | bytes], + output_path: str | Path | None = None, + labels: list[str] | None = None, +) -> Image.Image | bytes: + """Create a collage OG image from multiple plot images. + + Creates a 2x3 grid (2 rows, 3 columns) with pyplots.ai branding: + - Large dominant logo and tagline at top + - 6 plots in 16:9 rounded cards arranged in 2 rows + - Labels below each card + + Args: + images: List of plot images (paths, PIL Images, or bytes), up to 6 + output_path: If provided, save to this path + labels: Optional list of labels for each image (e.g., library names) + + Returns: + PIL Image if output_path is None, otherwise bytes of PNG + """ + if not images: + raise ValueError("At least one image is required") + + # Load all images (max 6 for 2x3 grid) + loaded_images: list[Image.Image] = [] + for img_input in images[:6]: + if isinstance(img_input, bytes): + img = Image.open(BytesIO(img_input)) + elif isinstance(img_input, Image.Image): + img = img_input + else: + img = Image.open(img_input) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + loaded_images.append(img) + + # Create final image (RGBA for card transparency) + final = Image.new("RGBA", (OG_WIDTH, OG_HEIGHT), PYPLOTS_BG) + draw = ImageDraw.Draw(final) + + # Layout constants + top_margin = 20 + side_margin = 40 + card_gap_x = 20 # Horizontal gap between cards + card_gap_y = 8 # Vertical gap between rows + card_padding = 6 + label_gap = 4 # Gap between card and label + bottom_margin = 15 + + # Draw logo (centered at top) + logo_font_size = 38 + logo_font = _get_font(logo_font_size) + logo_text = "pyplots.ai" + logo_bbox = draw.textbbox((0, 0), logo_text, font=logo_font) + logo_width = logo_bbox[2] - logo_bbox[0] + logo_x = (OG_WIDTH - logo_width) // 2 + logo_y = top_margin + _draw_pyplots_logo(draw, logo_x, logo_y, logo_font_size) + + # Draw tagline (matches website style - lowercase) + tagline = "library-agnostic, ai-powered python plotting." + tagline_font = _get_font(18, weight=400) + tagline_bbox = draw.textbbox((0, 0), tagline, font=tagline_font) + tagline_width = tagline_bbox[2] - tagline_bbox[0] + tagline_x = (OG_WIDTH - tagline_width) // 2 + tagline_y = top_margin + 58 # More space after logo + draw.text((tagline_x, tagline_y), tagline, fill="#6b7280", font=tagline_font) + + # Grid layout: 2 rows x 3 columns + cols = 3 + rows = 2 + + # Label font and height + label_font = _get_font(13, weight=400) + label_height = 18 + + # Calculate card area + header_height = tagline_y + 35 + grid_top = header_height + grid_bottom = OG_HEIGHT - bottom_margin + + # Available space for grid + available_width = OG_WIDTH - 2 * side_margin - (cols - 1) * card_gap_x + available_height = grid_bottom - grid_top - (rows - 1) * card_gap_y - rows * (label_height + label_gap) + + # Card slot dimensions + slot_width = available_width // cols + slot_height = available_height // rows + + # Card inner dimensions (16:9 aspect ratio) + # Calculate max inner size that fits in slot while being 16:9 + inner_aspect = 16 / 9 + slot_inner_width = slot_width - 2 * card_padding + slot_inner_height = slot_height - 2 * card_padding + + if slot_inner_width / slot_inner_height > inner_aspect: + # Slot is wider than 16:9, constrain by height + inner_height = slot_inner_height + inner_width = int(inner_height * inner_aspect) + else: + # Slot is taller than 16:9, constrain by width + inner_width = slot_inner_width + inner_height = int(inner_width / inner_aspect) + + for i, img in enumerate(loaded_images): + row = i // cols + col = i % cols + + # Slot position + slot_x = side_margin + col * (slot_width + card_gap_x) + slot_y = grid_top + row * (slot_height + card_gap_y + label_height + label_gap) + + # Scale image to fit in 16:9 inner area + scale = min(inner_width / img.width, inner_height / img.height) + new_width = int(img.width * scale) + new_height = int(img.height * scale) + + # Resize image + resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Center card in slot + actual_card_width = new_width + 2 * card_padding + actual_card_height = new_height + 2 * card_padding + card_x = slot_x + (slot_width - actual_card_width) // 2 + card_y = slot_y + (slot_height - actual_card_height) // 2 + + # Draw card + _draw_rounded_card(final, resized, card_x, card_y, padding=card_padding, radius=10, shadow_offset=2) + + # Add label below card + if labels and i < len(labels): + label = labels[i] + draw = ImageDraw.Draw(final) + bbox = draw.textbbox((0, 0), label, font=label_font) + lbl_width = bbox[2] - bbox[0] + label_x = slot_x + (slot_width - lbl_width) // 2 + label_y = card_y + actual_card_height + label_gap + draw.text((label_x, label_y), label, fill=PYPLOTS_DARK, font=label_font) + + # Convert to RGB for PNG output + final_rgb = Image.new("RGB", final.size, PYPLOTS_BG) + final_rgb.paste(final, mask=final.split()[3] if final.mode == "RGBA" else None) + + if output_path: + final_rgb.save(output_path, "PNG", optimize=True) + return final_rgb + + # Return as bytes + buffer = BytesIO() + final_rgb.save(buffer, "PNG", optimize=True) + return buffer.getvalue() + + if __name__ == "__main__": import sys @@ -138,10 +595,14 @@ def print_usage() -> None: print("Usage:") print(" python -m core.images thumbnail [width]") print(" python -m core.images process [thumb]") + print(" python -m core.images brand [spec_id] [library]") + print(" python -m core.images collage [img2] [img3] [img4]") print("") print("Examples:") print(" python -m core.images thumbnail plot.png thumb.png 400") print(" python -m core.images process plot.png out.png thumb.png") + print(" python -m core.images brand plot.png og.png scatter-basic matplotlib") + print(" python -m core.images collage og.png img1.png img2.png img3.png img4.png") sys.exit(1) if len(sys.argv) < 2: @@ -165,6 +626,23 @@ def print_usage() -> None: res = process_plot_image(input_file, output_file, thumb_file) print(f"Processed: {res}") + elif command == "brand": + if len(sys.argv) < 4: + print_usage() + input_file, output_file = sys.argv[2], sys.argv[3] + spec_id = sys.argv[4] if len(sys.argv) > 4 else None + library = sys.argv[5] if len(sys.argv) > 5 else None + create_branded_og_image(input_file, output_file, spec_id, library) + print(f"Branded OG image: {output_file} ({OG_WIDTH}x{OG_HEIGHT}px)") + + elif command == "collage": + if len(sys.argv) < 4: + print_usage() + output_file = sys.argv[2] + input_files = sys.argv[3:] + create_og_collage(input_files, output_file) + print(f"Collage: {output_file} ({OG_WIDTH}x{OG_HEIGHT}px, {len(input_files)} images)") + else: print(f"Unknown command: {command}") print_usage() diff --git a/docs/architecture/seo.md b/docs/architecture/seo.md new file mode 100644 index 0000000000..a7cadb5ac0 --- /dev/null +++ b/docs/architecture/seo.md @@ -0,0 +1,288 @@ +# SEO Architecture + +This document describes the SEO infrastructure for pyplots.ai, including bot detection, dynamic meta tags, branded og:images, and sitemap generation. + +## Overview + +pyplots.ai is a React SPA (Single Page Application). SPAs have a fundamental SEO challenge: social media bots and search engine crawlers cannot execute JavaScript, so they see an empty page without proper meta tags. + +Our solution uses **nginx-based bot detection** to serve pre-rendered HTML with correct `og:tags` to bots, while regular users get the full SPA experience. + +## Architecture Diagram + +``` + ┌─────────────────────┐ + │ Social Media Bot │ + │ (Twitter, FB, etc) │ + └──────────┬──────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ nginx (Frontend) │ +│ │ +│ 1. Check User-Agent against bot list │ +│ 2. If bot → proxy to api.pyplots.ai/seo-proxy/* │ +│ 3. If human → serve React SPA (index.html) │ +└──────────────────────────────────────────────────────────────────────┘ + │ │ + │ Bot │ Human + ▼ ▼ + ┌───────────────────────────┐ ┌───────────────────────────┐ + │ Backend API (FastAPI) │ │ React SPA │ + │ │ │ │ + │ /seo-proxy/* │ │ Client-side routing │ + │ Returns HTML with: │ │ Dynamic content │ + │ - og:title │ │ Full interactivity │ + │ - og:description │ │ │ + │ - og:image (branded) │ │ │ + └───────────────────────────┘ └───────────────────────────┘ + │ + │ og:image URL + ▼ + ┌───────────────────────────┐ + │ /og/{spec_id}.png │ ← Collage (2x3 grid, top 6 by quality) + │ /og/{spec_id}/{lib}.png │ ← Single branded implementation + │ │ + │ Dynamically generated │ + │ 1-hour cache │ + └───────────────────────────┘ +``` + +## Bot Detection + +### Detected Bots + +nginx detects 27 bots via User-Agent matching, organized by category: + +**Social Media:** +| Bot | User-Agent Pattern | +|-----|-------------------| +| Twitter/X | `twitterbot` | +| Facebook | `facebookexternalhit` | +| LinkedIn | `linkedinbot` | +| Pinterest | `pinterestbot` | +| Reddit | `redditbot` | +| Tumblr | `tumblr` | +| Mastodon | `mastodon` | + +**Messaging Apps:** +| Bot | User-Agent Pattern | +|-----|-------------------| +| Slack | `slackbot` | +| Discord | `discordbot` | +| Telegram | `telegrambot` | +| WhatsApp | `whatsapp` | +| Signal | `signal` | +| Viber | `viber` | +| Skype/Teams | `skypeuripreview` | +| Microsoft Teams | `microsoft teams` | +| Snapchat | `snapchat` | + +**Search Engines:** +| Bot | User-Agent Pattern | +|-----|-------------------| +| Google | `googlebot` | +| Bing | `bingbot` | +| Yandex | `yandexbot` | +| DuckDuckGo | `duckduckbot` | +| Baidu | `baiduspider` | +| Apple | `applebot` | + +**Link Preview Services:** +| Bot | User-Agent Pattern | +|-----|-------------------| +| Embedly | `embedly` | +| Quora | `quora link preview` | +| Outbrain | `outbrain` | +| Rogerbot | `rogerbot` | +| Showyoubot | `showyoubot` | + +### nginx Configuration + +Located in `app/nginx.conf`: + +```nginx +# Bot detection map +map $http_user_agent $is_bot { + default 0; + ~*twitterbot 1; + ~*facebookexternalhit 1; + # ... more bots +} + +# SPA routing with bot detection +location / { + error_page 418 = @seo_proxy; + if ($is_bot) { + return 418; # Trigger proxy to backend + } + try_files $uri $uri/ /index.html; +} + +# Named location for bot proxy +location @seo_proxy { + proxy_pass https://api.pyplots.ai/seo-proxy$request_uri; +} +``` + +## SEO Proxy Endpoints + +Backend endpoints that serve HTML with correct meta tags for bots. + +**Router**: `api/routers/seo.py` + +### Endpoints + +| Endpoint | Purpose | og:image | +|----------|---------|----------| +| `GET /seo-proxy/` | Home page | Default (`og-image.png`) | +| `GET /seo-proxy/catalog` | Catalog page | Default | +| `GET /seo-proxy/{spec_id}` | Spec overview | Collage (2x3 grid) | +| `GET /seo-proxy/{spec_id}/{library}` | Implementation | Single branded | + +### HTML Template + +All SEO proxy endpoints return minimal HTML with meta tags: + +```html + + + + + {title} + + + + + + + + + + + + + +

{title}

{description}

+ +``` + +## Branded OG Images + +Dynamically generated preview images with pyplots.ai branding. + +**Router**: `api/routers/og_images.py` +**Image Processing**: `core/images.py` + +### Endpoints + +| Endpoint | Description | Dimensions | +|----------|-------------|------------| +| `GET /og/{spec_id}.png` | Collage of top 6 implementations | 1200x630 | +| `GET /og/{spec_id}/{library}.png` | Single branded implementation | 1200x630 | + +### Single Implementation Image + +Layout: +- pyplots.ai logo (centered, MonoLisa font 42px, weight 700) +- Tagline: "Beautiful Python plotting made easy." +- Plot image in rounded card with shadow +- Label: `{spec_id} · {library}` + +### Collage Image (Spec Overview) + +Layout: +- pyplots.ai logo (centered, MonoLisa font 38px) +- Tagline +- 2x3 grid of top 6 implementations (sorted by `quality_score` descending) +- Each plot in 16:9 rounded card with label below + +### Caching + +- **TTL**: 1 hour (3600 seconds) +- **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage` +- **Storage**: In-memory API cache + +### Font + +Uses **MonoLisa** variable font (commercial, not in repo): +- Downloaded from GCS: `gs://pyplots-static/fonts/MonoLisaVariableNormal.ttf` +- Cached locally in `/tmp/pyplots-fonts/` +- Fallback: DejaVuSansMono-Bold + +## Sitemap + +Dynamic XML sitemap for search engine indexing. + +### Endpoint + +`GET /sitemap.xml` (proxied from frontend nginx to backend) + +### Structure + +```xml + + + https://pyplots.ai/ + https://pyplots.ai/catalog + + https://pyplots.ai/{spec_id} + https://pyplots.ai/{spec_id}/{library} + + +``` + +### Included URLs + +1. Home page (`/`) +2. Catalog page (`/catalog`) +3. Spec overview pages (`/{spec_id}`) - only if spec has implementations +4. Implementation pages (`/{spec_id}/{library}`) - all implementations + +### nginx Proxy + +```nginx +location = /sitemap.xml { + proxy_pass https://api.pyplots.ai/sitemap.xml; +} +``` + +## Testing + +### Test Bot Detection Locally + +```bash +# Simulate Twitter bot +curl -H "User-Agent: Twitterbot/1.0" https://pyplots.ai/scatter-basic + +# Should return HTML with og:tags, not React SPA +``` + +### Test OG Images + +```bash +# Single implementation +curl -o test.png https://api.pyplots.ai/og/scatter-basic/matplotlib.png + +# Collage +curl -o test.png https://api.pyplots.ai/og/scatter-basic.png +``` + +### Validate with Social Media Debuggers + +- **LinkedIn**: https://www.linkedin.com/post-inspector/ + +## Files + +| File | Purpose | +|------|---------| +| `app/nginx.conf` | Bot detection, SPA routing, sitemap proxy | +| `api/routers/seo.py` | SEO proxy endpoints, sitemap generation | +| `api/routers/og_images.py` | Branded og:image endpoints | +| `core/images.py` | Image processing, branding functions | + +## Security + +- All user input (spec_id, library) is HTML-escaped before rendering +- XSS prevention via `html.escape()` for all dynamic content +- og:image URLs use `html.escape(url, quote=True)` to prevent attribute injection diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 04432ed404..0c6c469777 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -2,19 +2,18 @@ E2E test fixtures with real PostgreSQL database. Uses a separate 'test' database to isolate test data from production. +Each test gets its own schema for complete isolation, allowing parallel execution. Tests are skipped if DATABASE_URL is not set or database is unreachable. Connection modes: - Local: Direct DATABASE_URL from .env (auto-derives test database) - CI: DATABASE_URL via Cloud SQL Proxy (localhost:5432 -> Cloud SQL) - Explicit: TEST_DATABASE_URL for custom test database - -Note: These tests must NOT be run with pytest-xdist parallelization -as multiple workers would conflict on the shared test database. """ import asyncio import os +import uuid import pytest import pytest_asyncio @@ -58,28 +57,32 @@ def _get_database_url(): @pytest_asyncio.fixture(scope="function") -async def pg_engine(): +async def pg_engine_with_schema(): """ - Create PostgreSQL engine for test database. + Create PostgreSQL engine for test database with isolated schema. - Uses a separate 'test' database to isolate tests from production. - Tables are dropped and recreated for each test. + Each test gets its own schema (test_) for complete isolation, + allowing tests to run in parallel without conflicts. Skips tests if database is unreachable. + + Returns tuple of (engine, schema_name). """ database_url = _get_database_url() if not database_url: pytest.skip("DATABASE_URL not set - skipping PostgreSQL E2E tests") + # Generate unique schema name for this test + schema_name = f"test_{uuid.uuid4().hex[:8]}" + engine = create_async_engine(database_url, echo=False, connect_args={"timeout": CONNECTION_TIMEOUT}) try: async with asyncio.timeout(CONNECTION_TIMEOUT + 2): async with engine.begin() as conn: - # Drop all tables in FK order (children before parents) - await conn.execute(text("DROP TABLE IF EXISTS impls CASCADE")) - await conn.execute(text("DROP TABLE IF EXISTS specs CASCADE")) - await conn.execute(text("DROP TABLE IF EXISTS libraries CASCADE")) - # Create fresh tables + # Create isolated schema for this test + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_name}")) + await conn.execute(text(f"SET search_path TO {schema_name}")) + # Create tables in this schema await conn.run_sync(Base.metadata.create_all) except (TimeoutError, asyncio.TimeoutError, OSError) as e: await engine.dispose() @@ -88,21 +91,33 @@ async def pg_engine(): await engine.dispose() pytest.skip(f"Database connection failed - skipping E2E tests: {e}") - yield engine + yield engine, schema_name - # Cleanup: Drop all tables in FK order (children before parents) - async with engine.begin() as conn: - await conn.execute(text("DROP TABLE IF EXISTS impls CASCADE")) - await conn.execute(text("DROP TABLE IF EXISTS specs CASCADE")) - await conn.execute(text("DROP TABLE IF EXISTS libraries CASCADE")) + # Cleanup: Drop the test schema + try: + async with engine.begin() as conn: + await conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")) + except Exception: + pass # Ignore cleanup errors await engine.dispose() @pytest_asyncio.fixture(scope="function") -async def pg_session(pg_engine): - """Create session for test database.""" - async_session = async_sessionmaker(pg_engine, class_=AsyncSession, expire_on_commit=False) +async def pg_engine(pg_engine_with_schema): + """Get just the engine from pg_engine_with_schema.""" + engine, _ = pg_engine_with_schema + return engine + + +@pytest_asyncio.fixture(scope="function") +async def pg_session(pg_engine_with_schema): + """Create session for test database with isolated schema.""" + engine, schema_name = pg_engine_with_schema + + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with async_session() as session: + # Set search_path to the test schema + await session.execute(text(f"SET search_path TO {schema_name}")) yield session await session.rollback() @@ -113,7 +128,7 @@ async def pg_db_with_data(pg_session): Seed test database with sample data. Creates the same test data as tests/conftest.py:test_db_with_data - but in the PostgreSQL test database. + but in the PostgreSQL test database (in an isolated schema). Uses atomic commit: libraries + specs flushed first (FK targets), then impls added and everything committed together. diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 2d15c0aa3b..d491aba81d 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -557,6 +557,175 @@ def test_seo_spec_implementation_fallback_image(self, db_client, mock_spec) -> N assert "og-image.png" in response.text # Default image used +class TestOgImagesRouter: + """Tests for OG image generation endpoints.""" + + def test_get_branded_impl_image_no_db(self, client: TestClient) -> None: + """Should return 503 when DB not available.""" + with patch(DB_CONFIG_PATCH, return_value=False): + response = client.get("/og/scatter-basic/matplotlib.png") + assert response.status_code == 503 + + def test_get_branded_impl_image_spec_not_found(self, db_client) -> None: + """Should return 404 when spec not found.""" + client, _ = db_client + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=None) + + with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): + response = client.get("/og/nonexistent/matplotlib.png") + assert response.status_code == 404 + + def test_get_branded_impl_image_impl_not_found(self, db_client, mock_spec) -> None: + """Should return 404 when implementation not found.""" + client, _ = db_client + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + + with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): + # Request a library that doesn't exist in mock_spec + response = client.get("/og/scatter-basic/nonexistent.png") + assert response.status_code == 404 + + def test_get_branded_impl_image_cached(self, db_client) -> None: + """Should return cached image when available.""" + client, _ = db_client + + cached_bytes = b"fake png data" + with patch("api.routers.og_images.get_cache", return_value=cached_bytes): + response = client.get("/og/scatter-basic/matplotlib.png") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.content == cached_bytes + + def test_get_spec_collage_no_db(self, client: TestClient) -> None: + """Should return 503 when DB not available.""" + with patch(DB_CONFIG_PATCH, return_value=False): + response = client.get("/og/scatter-basic.png") + assert response.status_code == 503 + + def test_get_spec_collage_spec_not_found(self, db_client) -> None: + """Should return 404 when spec not found.""" + client, _ = db_client + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=None) + + with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): + response = client.get("/og/nonexistent.png") + assert response.status_code == 404 + + def test_get_spec_collage_no_previews(self, db_client) -> None: + """Should return 404 when no implementations have previews.""" + client, _ = db_client + + mock_impl = MagicMock() + mock_impl.library_id = "matplotlib" + mock_impl.preview_url = None # No preview + + mock_spec = MagicMock() + mock_spec.id = "scatter-basic" + mock_spec.impls = [mock_impl] + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + + with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): + response = client.get("/og/scatter-basic.png") + assert response.status_code == 404 + + def test_get_spec_collage_cached(self, db_client) -> None: + """Should return cached collage when available.""" + client, _ = db_client + + cached_bytes = b"fake collage png data" + with patch("api.routers.og_images.get_cache", return_value=cached_bytes): + response = client.get("/og/scatter-basic.png") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.content == cached_bytes + + def test_get_branded_impl_image_success(self, db_client, mock_spec) -> None: + """Should generate branded image when not cached.""" + client, _ = db_client + + fake_image_bytes = b"fake source image" + fake_branded_bytes = b"fake branded png" + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + + with ( + patch("api.routers.og_images.get_cache", return_value=None), + patch("api.routers.og_images.set_cache"), + patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo), + patch("api.routers.og_images._fetch_image", new_callable=AsyncMock, return_value=fake_image_bytes), + patch("api.routers.og_images.create_branded_og_image", return_value=fake_branded_bytes), + ): + response = client.get("/og/scatter-basic/matplotlib.png") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["cache-control"] == "public, max-age=3600" + assert response.content == fake_branded_bytes + + def test_get_spec_collage_success(self, db_client) -> None: + """Should generate collage when not cached.""" + client, _ = db_client + + # Create mock implementations with different quality scores + mock_impls = [] + for i, lib in enumerate(["matplotlib", "seaborn", "plotly"]): + impl = MagicMock() + impl.library_id = lib + impl.preview_url = f"https://example.com/{lib}.png" + impl.quality_score = 90 - i * 5 # 90, 85, 80 + mock_impls.append(impl) + + mock_spec = MagicMock() + mock_spec.id = "scatter-basic" + mock_spec.impls = mock_impls + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + + fake_collage_bytes = b"fake collage png" + + with ( + patch("api.routers.og_images.get_cache", return_value=None), + patch("api.routers.og_images.set_cache"), + patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo), + patch("api.routers.og_images._fetch_image", new_callable=AsyncMock, return_value=b"fake image"), + patch("api.routers.og_images.create_og_collage", return_value=fake_collage_bytes), + ): + response = client.get("/og/scatter-basic.png") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["cache-control"] == "public, max-age=3600" + assert response.content == fake_collage_bytes + + def test_get_branded_impl_image_cached_has_cache_control(self, db_client) -> None: + """Cached response should include Cache-Control header.""" + client, _ = db_client + + cached_bytes = b"fake png data" + with patch("api.routers.og_images.get_cache", return_value=cached_bytes): + response = client.get("/og/scatter-basic/matplotlib.png") + assert response.status_code == 200 + assert response.headers["cache-control"] == "public, max-age=3600" + + def test_get_spec_collage_cached_has_cache_control(self, db_client) -> None: + """Cached collage response should include Cache-Control header.""" + client, _ = db_client + + cached_bytes = b"fake collage png data" + with patch("api.routers.og_images.get_cache", return_value=cached_bytes): + response = client.get("/og/scatter-basic.png") + assert response.status_code == 200 + assert response.headers["cache-control"] == "public, max-age=3600" + + class TestPlotsRouter: """Tests for plots filter router.""" diff --git a/tests/unit/core/test_images.py b/tests/unit/core/test_images.py index c31d6b9065..fae3258dda 100644 --- a/tests/unit/core/test_images.py +++ b/tests/unit/core/test_images.py @@ -287,6 +287,17 @@ def test_large_image(self, tmp_path: Path) -> None: class TestCLI: """Tests for command-line interface.""" + @pytest.fixture(autouse=True) + def clean_module_cache(self): + """Remove core.images from sys.modules to avoid runpy warning.""" + import sys + + # Remove module before test to allow clean runpy execution + sys.modules.pop("core.images", None) + yield + # Clean up after test as well + sys.modules.pop("core.images", None) + def test_cli_thumbnail_command(self, sample_image: Path, tmp_path: Path, monkeypatch, capsys) -> None: """Should run thumbnail command from CLI.""" import sys