Files
rose-ash/artdag_common/rendering.py
giles ea9015f65b Squashed 'common/' content from commit ff185b4
git-subtree-dir: common
git-subtree-split: ff185b42f0fa577446c3d00da3438dc148ee8102
2026-02-24 23:08:41 +00:00

161 lines
4.3 KiB
Python

"""
Jinja2 template rendering system for Art-DAG servers.
Provides a unified template environment that can load from:
1. The shared artdag_common/templates directory
2. App-specific template directories
Usage:
from artdag_common import create_jinja_env, render
# In app initialization
templates = create_jinja_env("app/templates")
# In route handler
return render(templates, "runs/detail.html", request, run=run, user=user)
"""
from pathlib import Path
from typing import Any, Optional, Union
from fastapi import Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader, select_autoescape
from .constants import (
TAILWIND_CDN,
HTMX_CDN,
CYTOSCAPE_CDN,
DAGRE_CDN,
CYTOSCAPE_DAGRE_CDN,
TAILWIND_CONFIG,
NODE_COLORS,
STATUS_COLORS,
)
def create_jinja_env(*template_dirs: Union[str, Path]) -> Environment:
"""
Create a Jinja2 environment with the shared templates and optional app-specific dirs.
Args:
*template_dirs: Additional template directories to search (app-specific)
Returns:
Configured Jinja2 Environment
Example:
env = create_jinja_env("/app/templates", "/app/custom")
"""
loaders = []
# Add app-specific directories first (higher priority)
for template_dir in template_dirs:
path = Path(template_dir)
if path.exists():
loaders.append(FileSystemLoader(str(path)))
# Add shared templates from this package (lower priority, fallback)
loaders.append(PackageLoader("artdag_common", "templates"))
env = Environment(
loader=ChoiceLoader(loaders),
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
)
# Add global context available to all templates
env.globals.update({
"TAILWIND_CDN": TAILWIND_CDN,
"HTMX_CDN": HTMX_CDN,
"CYTOSCAPE_CDN": CYTOSCAPE_CDN,
"DAGRE_CDN": DAGRE_CDN,
"CYTOSCAPE_DAGRE_CDN": CYTOSCAPE_DAGRE_CDN,
"TAILWIND_CONFIG": TAILWIND_CONFIG,
"NODE_COLORS": NODE_COLORS,
"STATUS_COLORS": STATUS_COLORS,
})
# Add custom filters
env.filters["truncate_hash"] = truncate_hash
env.filters["format_size"] = format_size
env.filters["status_color"] = status_color
return env
def render(
env: Environment,
template_name: str,
request: Request,
status_code: int = 200,
**context: Any,
) -> HTMLResponse:
"""
Render a template to an HTMLResponse.
Args:
env: Jinja2 environment
template_name: Template file path (e.g., "runs/detail.html")
request: FastAPI request object
status_code: HTTP status code (default 200)
**context: Template context variables
Returns:
HTMLResponse with rendered content
"""
template = env.get_template(template_name)
html = template.render(request=request, **context)
return HTMLResponse(html, status_code=status_code)
def render_fragment(
env: Environment,
template_name: str,
**context: Any,
) -> str:
"""
Render a template fragment to a string (for HTMX partial updates).
Args:
env: Jinja2 environment
template_name: Template file path
**context: Template context variables
Returns:
Rendered HTML string
"""
template = env.get_template(template_name)
return template.render(**context)
# Custom Jinja2 filters
def truncate_hash(value: str, length: int = 16) -> str:
"""Truncate a hash to specified length with ellipsis."""
if not value:
return ""
if len(value) <= length:
return value
return f"{value[:length]}..."
def format_size(size_bytes: Optional[int]) -> str:
"""Format file size in human-readable form."""
if size_bytes is None:
return "Unknown"
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
def status_color(status: str) -> str:
"""Get Tailwind CSS class for a status."""
return STATUS_COLORS.get(status, STATUS_COLORS["pending"])