From ea9015f65baf39d0fa4b7d8451f04a89b57d9fb9 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Feb 2026 23:08:41 +0000 Subject: [PATCH] Squashed 'common/' content from commit ff185b4 git-subtree-dir: common git-subtree-split: ff185b42f0fa577446c3d00da3438dc148ee8102 --- README.md | 293 ++++++++++++++++++ artdag_common/__init__.py | 18 ++ artdag_common/constants.py | 76 +++++ artdag_common/fragments.py | 91 ++++++ artdag_common/middleware/__init__.py | 16 + .../__pycache__/auth.cpython-310.pyc | Bin 0 -> 6751 bytes artdag_common/middleware/auth.py | 276 +++++++++++++++++ .../middleware/content_negotiation.py | 174 +++++++++++ artdag_common/models/__init__.py | 25 ++ artdag_common/models/requests.py | 74 +++++ artdag_common/models/responses.py | 96 ++++++ artdag_common/rendering.py | 160 ++++++++++ artdag_common/templates/_base.html | 91 ++++++ artdag_common/templates/components/badge.html | 64 ++++ artdag_common/templates/components/card.html | 45 +++ artdag_common/templates/components/dag.html | 176 +++++++++++ .../templates/components/media_preview.html | 98 ++++++ .../templates/components/pagination.html | 82 +++++ artdag_common/templates/components/table.html | 51 +++ artdag_common/utils/__init__.py | 19 ++ artdag_common/utils/formatting.py | 165 ++++++++++ artdag_common/utils/media.py | 166 ++++++++++ artdag_common/utils/pagination.py | 85 +++++ pyproject.toml | 22 ++ 24 files changed, 2363 insertions(+) create mode 100644 README.md create mode 100644 artdag_common/__init__.py create mode 100644 artdag_common/constants.py create mode 100644 artdag_common/fragments.py create mode 100644 artdag_common/middleware/__init__.py create mode 100644 artdag_common/middleware/__pycache__/auth.cpython-310.pyc create mode 100644 artdag_common/middleware/auth.py create mode 100644 artdag_common/middleware/content_negotiation.py create mode 100644 artdag_common/models/__init__.py create mode 100644 artdag_common/models/requests.py create mode 100644 artdag_common/models/responses.py create mode 100644 artdag_common/rendering.py create mode 100644 artdag_common/templates/_base.html create mode 100644 artdag_common/templates/components/badge.html create mode 100644 artdag_common/templates/components/card.html create mode 100644 artdag_common/templates/components/dag.html create mode 100644 artdag_common/templates/components/media_preview.html create mode 100644 artdag_common/templates/components/pagination.html create mode 100644 artdag_common/templates/components/table.html create mode 100644 artdag_common/utils/__init__.py create mode 100644 artdag_common/utils/formatting.py create mode 100644 artdag_common/utils/media.py create mode 100644 artdag_common/utils/pagination.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..73d1dd5 --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# artdag-common + +Shared components for Art-DAG L1 (celery) and L2 (activity-pub) servers. + +## Features + +- **Jinja2 Templating**: Unified template environment with shared base templates +- **Reusable Components**: Cards, tables, pagination, DAG visualization, media preview +- **Authentication Middleware**: Cookie and JWT token parsing +- **Content Negotiation**: HTML/JSON/ActivityPub format detection +- **Utility Functions**: Hash truncation, file size formatting, status colors + +## Installation + +```bash +pip install -e /path/to/artdag-common + +# Or add to requirements.txt +-e file:../common +``` + +## Quick Start + +```python +from fastapi import FastAPI, Request +from artdag_common import create_jinja_env, render + +app = FastAPI() + +# Initialize templates with app-specific directory +templates = create_jinja_env("app/templates") + +@app.get("/") +async def home(request: Request): + return render(templates, "home.html", request, title="Home") +``` + +## Package Structure + +``` +artdag_common/ +├── __init__.py # Package exports +├── constants.py # CDN URLs, colors, configs +├── rendering.py # Jinja2 environment and helpers +├── middleware/ +│ ├── auth.py # Authentication utilities +│ └── content_negotiation.py # Accept header parsing +├── models/ +│ ├── requests.py # Shared request models +│ └── responses.py # Shared response models +├── utils/ +│ ├── formatting.py # Text/date formatting +│ ├── media.py # Media type detection +│ └── pagination.py # Pagination helpers +└── templates/ + ├── base.html # Base layout template + └── components/ + ├── badge.html # Status/type badges + ├── card.html # Info cards + ├── dag.html # Cytoscape DAG visualization + ├── media_preview.html # Video/image/audio preview + ├── pagination.html # HTMX pagination + └── table.html # Styled tables +``` + +## Jinja2 Templates + +### Base Template + +The `base.html` template provides: +- Dark theme with Tailwind CSS +- HTMX integration +- Navigation slot +- Content block +- Optional Cytoscape.js block + +```html +{% extends "base.html" %} + +{% block title %}My Page{% endblock %} + +{% block content %} +

Hello World

+{% endblock %} +``` + +### Reusable Components + +#### Card + +```html +{% include "components/card.html" %} +``` + +```html + +
+ {% block card_title %}Title{% endblock %} + {% block card_content %}Content{% endblock %} +
+``` + +#### Badge + +Status and type badges with appropriate colors: + +```html +{% from "components/badge.html" import status_badge, type_badge %} + +{{ status_badge("completed") }} +{{ status_badge("failed") }} +{{ type_badge("video") }} +``` + +#### DAG Visualization + +Interactive Cytoscape.js graph: + +```html +{% include "components/dag.html" %} +``` + +Requires passing `nodes` and `edges` data to template context. + +#### Media Preview + +Responsive media preview with format detection: + +```html +{% include "components/media_preview.html" %} +``` + +Supports video, audio, and image formats. + +#### Pagination + +HTMX-powered infinite scroll pagination: + +```html +{% include "components/pagination.html" %} +``` + +## Template Rendering + +### Full Page Render + +```python +from artdag_common import render + +@app.get("/runs/{run_id}") +async def run_detail(run_id: str, request: Request): + run = get_run(run_id) + return render(templates, "runs/detail.html", request, run=run) +``` + +### Fragment Render (HTMX) + +```python +from artdag_common import render_fragment + +@app.get("/runs/{run_id}/status") +async def run_status_fragment(run_id: str): + run = get_run(run_id) + html = render_fragment(templates, "components/status.html", status=run.status) + return HTMLResponse(html) +``` + +## Authentication Middleware + +### UserContext + +```python +from artdag_common.middleware.auth import UserContext, get_user_from_cookie + +@app.get("/profile") +async def profile(request: Request): + user = get_user_from_cookie(request) + if not user: + return RedirectResponse("/login") + return {"username": user.username, "actor_id": user.actor_id} +``` + +### Token Parsing + +```python +from artdag_common.middleware.auth import get_user_from_header, decode_jwt_claims + +@app.get("/api/me") +async def api_me(request: Request): + user = get_user_from_header(request) + if not user: + raise HTTPException(401, "Not authenticated") + return {"user": user.username} +``` + +## Content Negotiation + +Detect what response format the client wants: + +```python +from artdag_common.middleware.content_negotiation import wants_html, wants_json, wants_activity_json + +@app.get("/users/{username}") +async def user_profile(username: str, request: Request): + user = get_user(username) + + if wants_activity_json(request): + return ActivityPubActor(user) + elif wants_json(request): + return user.dict() + else: + return render(templates, "users/profile.html", request, user=user) +``` + +## Constants + +### CDN URLs + +```python +from artdag_common import TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN + +# Available in templates as globals: +# {{ TAILWIND_CDN }} +# {{ HTMX_CDN }} +# {{ CYTOSCAPE_CDN }} +``` + +### Node Colors + +```python +from artdag_common import NODE_COLORS + +# { +# "SOURCE": "#3b82f6", # Blue +# "EFFECT": "#22c55e", # Green +# "OUTPUT": "#a855f7", # Purple +# "ANALYSIS": "#f59e0b", # Amber +# "_LIST": "#6366f1", # Indigo +# "default": "#6b7280", # Gray +# } +``` + +### Status Colors + +```python +STATUS_COLORS = { + "completed": "bg-green-600", + "cached": "bg-blue-600", + "running": "bg-yellow-600", + "pending": "bg-gray-600", + "failed": "bg-red-600", +} +``` + +## Custom Jinja2 Filters + +The following filters are available in all templates: + +| Filter | Usage | Description | +|--------|-------|-------------| +| `truncate_hash` | `{{ hash\|truncate_hash }}` | Shorten hash to 16 chars with ellipsis | +| `format_size` | `{{ bytes\|format_size }}` | Format bytes as KB/MB/GB | +| `status_color` | `{{ status\|status_color }}` | Get Tailwind class for status | + +Example: + +```html + + {{ run.status }} + + +{{ content_hash|truncate_hash }} + +{{ file_size|format_size }} +``` + +## Development + +```bash +cd /root/art-dag/common + +# Install in development mode +pip install -e . + +# Run tests +pytest +``` + +## Dependencies + +- `fastapi>=0.100.0` - Web framework +- `jinja2>=3.1.0` - Templating engine +- `pydantic>=2.0.0` - Data validation diff --git a/artdag_common/__init__.py b/artdag_common/__init__.py new file mode 100644 index 0000000..e7fd6e5 --- /dev/null +++ b/artdag_common/__init__.py @@ -0,0 +1,18 @@ +""" +Art-DAG Common Library + +Shared components for L1 (celery) and L2 (activity-pub) servers. +""" + +from .constants import NODE_COLORS, TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN +from .rendering import create_jinja_env, render, render_fragment + +__all__ = [ + "NODE_COLORS", + "TAILWIND_CDN", + "HTMX_CDN", + "CYTOSCAPE_CDN", + "create_jinja_env", + "render", + "render_fragment", +] diff --git a/artdag_common/constants.py b/artdag_common/constants.py new file mode 100644 index 0000000..ee8862d --- /dev/null +++ b/artdag_common/constants.py @@ -0,0 +1,76 @@ +""" +Shared constants for Art-DAG servers. +""" + +# CDN URLs +TAILWIND_CDN = "https://cdn.tailwindcss.com?plugins=typography" +HTMX_CDN = "https://unpkg.com/htmx.org@1.9.10" +CYTOSCAPE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js" +DAGRE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js" +CYTOSCAPE_DAGRE_CDN = "https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js" + +# Node colors for DAG visualization +NODE_COLORS = { + "SOURCE": "#3b82f6", # Blue - input sources + "EFFECT": "#22c55e", # Green - processing effects + "OUTPUT": "#a855f7", # Purple - final outputs + "ANALYSIS": "#f59e0b", # Amber - analysis nodes + "_LIST": "#6366f1", # Indigo - list aggregation + "default": "#6b7280", # Gray - unknown types +} + +# Status colors +STATUS_COLORS = { + "completed": "bg-green-600", + "cached": "bg-blue-600", + "running": "bg-yellow-600", + "pending": "bg-gray-600", + "failed": "bg-red-600", +} + +# Tailwind dark theme configuration +TAILWIND_CONFIG = """ + + +""" + +# Default pagination settings +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 100 diff --git a/artdag_common/fragments.py b/artdag_common/fragments.py new file mode 100644 index 0000000..321949b --- /dev/null +++ b/artdag_common/fragments.py @@ -0,0 +1,91 @@ +"""Fragment client for fetching HTML fragments from coop apps. + +Lightweight httpx-based client (no Quart dependency) for Art-DAG to consume +coop app fragments like nav-tree, auth-menu, and cart-mini. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Sequence + +import httpx + +log = logging.getLogger(__name__) + +FRAGMENT_HEADER = "X-Fragment-Request" + +_client: httpx.AsyncClient | None = None +_DEFAULT_TIMEOUT = 2.0 + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + timeout=httpx.Timeout(_DEFAULT_TIMEOUT), + follow_redirects=False, + ) + return _client + + +def _internal_url(app_name: str) -> str: + """Resolve internal base URL for a coop app. + + Looks up ``INTERNAL_URL_{APP}`` first, falls back to ``http://{app}:8000``. + """ + env_key = f"INTERNAL_URL_{app_name.upper()}" + return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/") + + +async def fetch_fragment( + app_name: str, + fragment_type: str, + *, + params: dict | None = None, + timeout: float = _DEFAULT_TIMEOUT, + required: bool = False, +) -> str: + """Fetch an HTML fragment from a coop app. + + Returns empty string on failure by default (required=False). + """ + base = _internal_url(app_name) + url = f"{base}/internal/fragments/{fragment_type}" + try: + resp = await _get_client().get( + url, + params=params, + headers={FRAGMENT_HEADER: "1"}, + timeout=timeout, + ) + if resp.status_code == 200: + return resp.text + msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}" + log.warning(msg) + if required: + raise RuntimeError(msg) + return "" + except RuntimeError: + raise + except Exception as exc: + msg = f"Fragment {app_name}/{fragment_type} failed: {exc}" + log.warning(msg) + if required: + raise RuntimeError(msg) from exc + return "" + + +async def fetch_fragments( + requests: Sequence[tuple[str, str, dict | None]], + *, + timeout: float = _DEFAULT_TIMEOUT, + required: bool = False, +) -> list[str]: + """Fetch multiple fragments concurrently.""" + return list(await asyncio.gather(*( + fetch_fragment(app, ftype, params=params, timeout=timeout, required=required) + for app, ftype, params in requests + ))) diff --git a/artdag_common/middleware/__init__.py b/artdag_common/middleware/__init__.py new file mode 100644 index 0000000..185158d --- /dev/null +++ b/artdag_common/middleware/__init__.py @@ -0,0 +1,16 @@ +""" +Middleware and FastAPI dependencies for Art-DAG servers. +""" + +from .auth import UserContext, get_user_from_cookie, get_user_from_header, require_auth +from .content_negotiation import wants_html, wants_json, ContentType + +__all__ = [ + "UserContext", + "get_user_from_cookie", + "get_user_from_header", + "require_auth", + "wants_html", + "wants_json", + "ContentType", +] diff --git a/artdag_common/middleware/__pycache__/auth.cpython-310.pyc b/artdag_common/middleware/__pycache__/auth.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a262852ded9cf12140f6429bd9d9817d96a8657 GIT binary patch literal 6751 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 literal 0 HcmV?d00001 diff --git a/artdag_common/middleware/auth.py b/artdag_common/middleware/auth.py new file mode 100644 index 0000000..b227894 --- /dev/null +++ b/artdag_common/middleware/auth.py @@ -0,0 +1,276 @@ +""" +Authentication middleware and dependencies. + +Provides common authentication patterns for L1 and L2 servers. +Each server can extend or customize these as needed. +""" + +from dataclasses import dataclass +from typing import Callable, Optional, Awaitable, Any +import base64 +import json + +from fastapi import Request, HTTPException, Depends +from fastapi.responses import RedirectResponse + + +@dataclass +class UserContext: + """User context extracted from authentication.""" + username: str + actor_id: str # Full actor ID like "@user@server.com" + token: Optional[str] = None + l2_server: Optional[str] = None # L2 server URL for this user + email: Optional[str] = None # User's email address + + @property + def display_name(self) -> str: + """Get display name (username without @ prefix).""" + return self.username.lstrip("@") + + +def get_user_from_cookie(request: Request) -> Optional[UserContext]: + """ + Extract user context from session cookie. + + Supports two cookie formats: + 1. artdag_session: base64-encoded JSON {"username": "user", "actor_id": "@user@server.com"} + 2. auth_token: raw JWT token (used by L1 servers) + + Args: + request: FastAPI request + + Returns: + UserContext if valid cookie found, None otherwise + """ + # Try artdag_session cookie first (base64-encoded JSON) + cookie = request.cookies.get("artdag_session") + if cookie: + try: + data = json.loads(base64.b64decode(cookie)) + username = data.get("username", "") + actor_id = data.get("actor_id", "") + if not actor_id and username: + actor_id = f"@{username}" + return UserContext( + username=username, + actor_id=actor_id, + email=data.get("email", ""), + ) + except (json.JSONDecodeError, ValueError, KeyError): + pass + + # Try auth_token cookie (raw JWT, used by L1) + token = request.cookies.get("auth_token") + if token: + claims = decode_jwt_claims(token) + if claims: + username = claims.get("username") or claims.get("sub", "") + actor_id = claims.get("actor_id") or claims.get("actor") + if not actor_id and username: + actor_id = f"@{username}" + return UserContext( + username=username, + actor_id=actor_id or "", + token=token, + email=claims.get("email", ""), + ) + + return None + + +def get_user_from_header(request: Request) -> Optional[UserContext]: + """ + Extract user context from Authorization header. + + Supports: + - Bearer format (JWT or opaque token) + - Basic format + + Args: + request: FastAPI request + + Returns: + UserContext if valid header found, None otherwise + """ + auth_header = request.headers.get("Authorization", "") + + if auth_header.startswith("Bearer "): + token = auth_header[7:] + # Attempt to decode JWT claims + claims = decode_jwt_claims(token) + if claims: + username = claims.get("username") or claims.get("sub", "") + actor_id = claims.get("actor_id") or claims.get("actor") + # Default actor_id to @username if not provided + if not actor_id and username: + actor_id = f"@{username}" + return UserContext( + username=username, + actor_id=actor_id or "", + token=token, + ) + + return None + + +def decode_jwt_claims(token: str) -> Optional[dict]: + """ + Decode JWT claims without verification. + + This is useful for extracting user info from a token + when full verification is handled elsewhere. + + Args: + token: JWT token string + + Returns: + Claims dict if valid JWT format, None otherwise + """ + try: + parts = token.split(".") + if len(parts) != 3: + return None + + # Decode payload (second part) + payload = parts[1] + # Add padding if needed + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + + return json.loads(base64.urlsafe_b64decode(payload)) + except (json.JSONDecodeError, ValueError): + return None + + +def create_auth_dependency( + token_validator: Optional[Callable[[str], Awaitable[Optional[dict]]]] = None, + allow_cookie: bool = True, + allow_header: bool = True, +): + """ + Create a customized auth dependency for a specific server. + + Args: + token_validator: Optional async function to validate tokens with backend + allow_cookie: Whether to check cookies for auth + allow_header: Whether to check Authorization header + + Returns: + FastAPI dependency function + """ + async def get_current_user(request: Request) -> Optional[UserContext]: + ctx = None + + # Try header first (API clients) + if allow_header: + ctx = get_user_from_header(request) + if ctx and token_validator: + # Validate token with backend + validated = await token_validator(ctx.token) + if not validated: + ctx = None + + # Fall back to cookie (browser) + if ctx is None and allow_cookie: + ctx = get_user_from_cookie(request) + + return ctx + + return get_current_user + + +async def require_auth(request: Request) -> UserContext: + """ + Dependency that requires authentication. + + Raises HTTPException 401 if not authenticated. + Use with Depends() in route handlers. + + Example: + @app.get("/protected") + async def protected_route(user: UserContext = Depends(require_auth)): + return {"user": user.username} + """ + # Try header first + ctx = get_user_from_header(request) + if ctx is None: + ctx = get_user_from_cookie(request) + + if ctx is None: + # Check Accept header to determine response type + accept = request.headers.get("accept", "") + if "text/html" in accept: + raise HTTPException( + status_code=302, + headers={"Location": "/login"} + ) + raise HTTPException( + status_code=401, + detail="Authentication required" + ) + + return ctx + + +def require_owner(resource_owner_field: str = "username"): + """ + Dependency factory that requires the user to own the resource. + + Args: + resource_owner_field: Field name on the resource that contains owner username + + Returns: + Dependency function + + Example: + @app.delete("/items/{item_id}") + async def delete_item( + item: Item = Depends(get_item), + user: UserContext = Depends(require_owner("created_by")) + ): + ... + """ + async def check_ownership( + request: Request, + user: UserContext = Depends(require_auth), + ) -> UserContext: + # The actual ownership check must be done in the route + # after fetching the resource + return user + + return check_ownership + + +def set_auth_cookie(response: Any, user: UserContext, max_age: int = 86400 * 30) -> None: + """ + Set authentication cookie on response. + + Args: + response: FastAPI response object + user: User context to store + max_age: Cookie max age in seconds (default 30 days) + """ + cookie_data = { + "username": user.username, + "actor_id": user.actor_id, + } + if user.email: + cookie_data["email"] = user.email + data = json.dumps(cookie_data) + cookie_value = base64.b64encode(data.encode()).decode() + + response.set_cookie( + key="artdag_session", + value=cookie_value, + max_age=max_age, + httponly=True, + samesite="lax", + secure=True, # Require HTTPS in production + ) + + +def clear_auth_cookie(response: Any) -> None: + """Clear authentication cookie.""" + response.delete_cookie(key="artdag_session") diff --git a/artdag_common/middleware/content_negotiation.py b/artdag_common/middleware/content_negotiation.py new file mode 100644 index 0000000..aaa47c8 --- /dev/null +++ b/artdag_common/middleware/content_negotiation.py @@ -0,0 +1,174 @@ +""" +Content negotiation utilities. + +Helps determine what response format the client wants. +""" + +from enum import Enum +from typing import Optional + +from fastapi import Request + + +class ContentType(Enum): + """Response content types.""" + HTML = "text/html" + JSON = "application/json" + ACTIVITY_JSON = "application/activity+json" + XML = "application/xml" + + +def wants_html(request: Request) -> bool: + """ + Check if the client wants HTML response. + + Returns True if: + - Accept header contains text/html + - Accept header contains application/xhtml+xml + - No Accept header (browser default) + + Args: + request: FastAPI request + + Returns: + True if HTML is preferred + """ + accept = request.headers.get("accept", "") + + # No accept header usually means browser + if not accept: + return True + + # Check for HTML preferences + if "text/html" in accept: + return True + if "application/xhtml" in accept: + return True + + return False + + +def wants_json(request: Request) -> bool: + """ + Check if the client wants JSON response. + + Returns True if: + - Accept header contains application/json + - Accept header does NOT contain text/html + - Request has .json suffix (convention) + + Args: + request: FastAPI request + + Returns: + True if JSON is preferred + """ + accept = request.headers.get("accept", "") + + # Explicit JSON preference + if "application/json" in accept: + # But not if HTML is also requested (browsers often send both) + if "text/html" not in accept: + return True + + # Check URL suffix convention + if request.url.path.endswith(".json"): + return True + + return False + + +def wants_activity_json(request: Request) -> bool: + """ + Check if the client wants ActivityPub JSON-LD response. + + Used for federation with other ActivityPub servers. + + Args: + request: FastAPI request + + Returns: + True if ActivityPub format is preferred + """ + accept = request.headers.get("accept", "") + + if "application/activity+json" in accept: + return True + if "application/ld+json" in accept: + return True + + return False + + +def get_preferred_type(request: Request) -> ContentType: + """ + Determine the preferred content type from Accept header. + + Args: + request: FastAPI request + + Returns: + ContentType enum value + """ + if wants_activity_json(request): + return ContentType.ACTIVITY_JSON + if wants_json(request): + return ContentType.JSON + return ContentType.HTML + + +def is_htmx_request(request: Request) -> bool: + """ + Check if this is an HTMX request (partial page update). + + HTMX requests set the HX-Request header. + + Args: + request: FastAPI request + + Returns: + True if this is an HTMX request + """ + return request.headers.get("HX-Request") == "true" + + +def get_htmx_target(request: Request) -> Optional[str]: + """ + Get the HTMX target element ID. + + Args: + request: FastAPI request + + Returns: + Target element ID or None + """ + return request.headers.get("HX-Target") + + +def get_htmx_trigger(request: Request) -> Optional[str]: + """ + Get the HTMX trigger element ID. + + Args: + request: FastAPI request + + Returns: + Trigger element ID or None + """ + return request.headers.get("HX-Trigger") + + +def is_ios_request(request: Request) -> bool: + """ + Check if request is from iOS device. + + Useful for video format selection (iOS prefers MP4). + + Args: + request: FastAPI request + + Returns: + True if iOS user agent detected + """ + user_agent = request.headers.get("user-agent", "").lower() + return "iphone" in user_agent or "ipad" in user_agent diff --git a/artdag_common/models/__init__.py b/artdag_common/models/__init__.py new file mode 100644 index 0000000..d0d43c7 --- /dev/null +++ b/artdag_common/models/__init__.py @@ -0,0 +1,25 @@ +""" +Shared Pydantic models for Art-DAG servers. +""" + +from .requests import ( + PaginationParams, + PublishRequest, + StorageConfigRequest, + MetadataUpdateRequest, +) +from .responses import ( + PaginatedResponse, + ErrorResponse, + SuccessResponse, +) + +__all__ = [ + "PaginationParams", + "PublishRequest", + "StorageConfigRequest", + "MetadataUpdateRequest", + "PaginatedResponse", + "ErrorResponse", + "SuccessResponse", +] diff --git a/artdag_common/models/requests.py b/artdag_common/models/requests.py new file mode 100644 index 0000000..1c34d45 --- /dev/null +++ b/artdag_common/models/requests.py @@ -0,0 +1,74 @@ +""" +Request models shared across L1 and L2 servers. +""" + +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field + +from ..constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE + + +class PaginationParams(BaseModel): + """Common pagination parameters.""" + page: int = Field(default=1, ge=1, description="Page number (1-indexed)") + limit: int = Field( + default=DEFAULT_PAGE_SIZE, + ge=1, + le=MAX_PAGE_SIZE, + description="Items per page" + ) + + @property + def offset(self) -> int: + """Calculate offset for database queries.""" + return (self.page - 1) * self.limit + + +class PublishRequest(BaseModel): + """Request to publish content to L2/storage.""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = Field(default=None, max_length=2000) + tags: List[str] = Field(default_factory=list) + storage_id: Optional[str] = Field(default=None, description="Target storage provider") + + +class MetadataUpdateRequest(BaseModel): + """Request to update content metadata.""" + name: Optional[str] = Field(default=None, max_length=255) + description: Optional[str] = Field(default=None, max_length=2000) + tags: Optional[List[str]] = Field(default=None) + metadata: Optional[Dict[str, Any]] = Field(default=None) + + +class StorageConfigRequest(BaseModel): + """Request to configure a storage provider.""" + provider_type: str = Field(..., description="Provider type (pinata, web3storage, local, etc.)") + name: str = Field(..., min_length=1, max_length=100) + api_key: Optional[str] = Field(default=None) + api_secret: Optional[str] = Field(default=None) + endpoint: Optional[str] = Field(default=None) + config: Optional[Dict[str, Any]] = Field(default_factory=dict) + is_default: bool = Field(default=False) + + +class RecipeRunRequest(BaseModel): + """Request to run a recipe.""" + recipe_id: str = Field(..., description="Recipe content hash or ID") + inputs: Dict[str, str] = Field(..., description="Map of input name to content hash") + features: List[str] = Field( + default=["beats", "energy"], + description="Analysis features to extract" + ) + + +class PlanRequest(BaseModel): + """Request to generate an execution plan.""" + recipe_yaml: str = Field(..., description="Recipe YAML content") + input_hashes: Dict[str, str] = Field(..., description="Map of input name to content hash") + features: List[str] = Field(default=["beats", "energy"]) + + +class ExecutePlanRequest(BaseModel): + """Request to execute a generated plan.""" + plan_json: str = Field(..., description="JSON-serialized execution plan") + run_id: Optional[str] = Field(default=None, description="Optional run ID for tracking") diff --git a/artdag_common/models/responses.py b/artdag_common/models/responses.py new file mode 100644 index 0000000..447e70c --- /dev/null +++ b/artdag_common/models/responses.py @@ -0,0 +1,96 @@ +""" +Response models shared across L1 and L2 servers. +""" + +from typing import Optional, List, Dict, Any, Generic, TypeVar +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class PaginatedResponse(BaseModel, Generic[T]): + """Generic paginated response.""" + data: List[Any] = Field(default_factory=list) + pagination: Dict[str, Any] = Field(default_factory=dict) + + @classmethod + def create( + cls, + items: List[Any], + page: int, + limit: int, + total: int, + ) -> "PaginatedResponse": + """Create a paginated response.""" + return cls( + data=items, + pagination={ + "page": page, + "limit": limit, + "total": total, + "has_more": page * limit < total, + "total_pages": (total + limit - 1) // limit, + } + ) + + +class ErrorResponse(BaseModel): + """Standard error response.""" + error: str = Field(..., description="Error message") + detail: Optional[str] = Field(default=None, description="Detailed error info") + code: Optional[str] = Field(default=None, description="Error code") + + +class SuccessResponse(BaseModel): + """Standard success response.""" + success: bool = Field(default=True) + message: Optional[str] = Field(default=None) + data: Optional[Dict[str, Any]] = Field(default=None) + + +class RunStatus(BaseModel): + """Run execution status.""" + run_id: str + status: str = Field(..., description="pending, running, completed, failed") + recipe: Optional[str] = None + plan_id: Optional[str] = None + output_hash: Optional[str] = None + output_ipfs_cid: Optional[str] = None + total_steps: int = 0 + cached_steps: int = 0 + completed_steps: int = 0 + error: Optional[str] = None + + +class CacheItemResponse(BaseModel): + """Cached content item response.""" + content_hash: str + media_type: Optional[str] = None + size: Optional[int] = None + name: Optional[str] = None + description: Optional[str] = None + tags: List[str] = Field(default_factory=list) + ipfs_cid: Optional[str] = None + created_at: Optional[str] = None + + +class RecipeResponse(BaseModel): + """Recipe response.""" + recipe_id: str + name: str + description: Optional[str] = None + inputs: List[Dict[str, Any]] = Field(default_factory=list) + outputs: List[str] = Field(default_factory=list) + node_count: int = 0 + created_at: Optional[str] = None + + +class StorageProviderResponse(BaseModel): + """Storage provider configuration response.""" + storage_id: str + provider_type: str + name: str + is_default: bool = False + is_connected: bool = False + usage_bytes: Optional[int] = None + pin_count: int = 0 diff --git a/artdag_common/rendering.py b/artdag_common/rendering.py new file mode 100644 index 0000000..e5edacf --- /dev/null +++ b/artdag_common/rendering.py @@ -0,0 +1,160 @@ +""" +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"]) diff --git a/artdag_common/templates/_base.html b/artdag_common/templates/_base.html new file mode 100644 index 0000000..deeb67b --- /dev/null +++ b/artdag_common/templates/_base.html @@ -0,0 +1,91 @@ + + + + + + {% block title %}Art-DAG{% endblock %} + + + + + + + + + + + + {% block head %}{% endblock %} + + + + + + {% block header %} + {# Coop-style header: sky banner with title, nav-tree, auth-menu, cart-mini #} +
+
+
+ {# Cart mini #} + {% block cart_mini %}{% endblock %} + + {# Site title #} + + + {# Desktop nav: nav-tree + auth-menu #} + +
+
+ {# Mobile auth #} +
+ {% block auth_menu_mobile %}{% endblock %} +
+
+ {% endblock %} + + {# App-specific sub-nav (Runs, Recipes, Effects, etc.) #} + {% block sub_nav %}{% endblock %} + +
+ {% block content %}{% endblock %} +
+ + {% block footer %}{% endblock %} + {% block scripts %}{% endblock %} + + diff --git a/artdag_common/templates/components/badge.html b/artdag_common/templates/components/badge.html new file mode 100644 index 0000000..8c9f484 --- /dev/null +++ b/artdag_common/templates/components/badge.html @@ -0,0 +1,64 @@ +{# +Badge component for status and type indicators. + +Usage: + {% from "components/badge.html" import badge, status_badge, type_badge %} + + {{ badge("Active", "green") }} + {{ status_badge("completed") }} + {{ type_badge("EFFECT") }} +#} + +{% macro badge(text, color="gray", class="") %} + + {{ text }} + +{% endmacro %} + +{% macro status_badge(status, class="") %} +{% set colors = { + "completed": "green", + "cached": "blue", + "running": "yellow", + "pending": "gray", + "failed": "red", + "active": "green", + "inactive": "gray", +} %} +{% set color = colors.get(status, "gray") %} + + {% if status == "running" %} + + + + + {% endif %} + {{ status | capitalize }} + +{% endmacro %} + +{% macro type_badge(node_type, class="") %} +{% set colors = { + "SOURCE": "blue", + "EFFECT": "green", + "OUTPUT": "purple", + "ANALYSIS": "amber", + "_LIST": "indigo", +} %} +{% set color = colors.get(node_type, "gray") %} + + {{ node_type }} + +{% endmacro %} + +{% macro role_badge(role, class="") %} +{% set colors = { + "input": "blue", + "output": "purple", + "intermediate": "gray", +} %} +{% set color = colors.get(role, "gray") %} + + {{ role | capitalize }} + +{% endmacro %} diff --git a/artdag_common/templates/components/card.html b/artdag_common/templates/components/card.html new file mode 100644 index 0000000..04f5c54 --- /dev/null +++ b/artdag_common/templates/components/card.html @@ -0,0 +1,45 @@ +{# +Card component for displaying information. + +Usage: + {% include "components/card.html" with title="Status", content="Active", class="col-span-2" %} + +Or as a block: + {% call card(title="Details") %} +

Card content here

+ {% endcall %} +#} + +{% macro card(title=None, class="") %} +
+ {% if title %} +

{{ title }}

+ {% endif %} +
+ {{ caller() if caller else "" }} +
+
+{% endmacro %} + +{% macro stat_card(title, value, color="white", class="") %} +
+
{{ value }}
+
{{ title }}
+
+{% endmacro %} + +{% macro info_card(title, items, class="") %} +
+ {% if title %} +

{{ title }}

+ {% endif %} +
+ {% for label, value in items %} +
+
{{ label }}
+
{{ value }}
+
+ {% endfor %} +
+
+{% endmacro %} diff --git a/artdag_common/templates/components/dag.html b/artdag_common/templates/components/dag.html new file mode 100644 index 0000000..fa17fdc --- /dev/null +++ b/artdag_common/templates/components/dag.html @@ -0,0 +1,176 @@ +{# +Cytoscape.js DAG visualization component. + +Usage: + {% from "components/dag.html" import dag_container, dag_scripts, dag_legend %} + + {# In head block #} + {{ dag_scripts() }} + + {# In content #} + {{ dag_container(id="plan-dag", height="400px") }} + {{ dag_legend() }} + + {# In scripts block #} + +#} + +{% macro dag_scripts() %} + + + + +{% endmacro %} + +{% macro dag_container(id="dag-container", height="400px", class="") %} +
+ +{% endmacro %} + +{% macro dag_legend(node_types=None) %} +{% set types = node_types or ["SOURCE", "EFFECT", "_LIST"] %} +
+ {% for type in types %} + + + {{ type }} + + {% endfor %} + + + Cached + +
+{% endmacro %} diff --git a/artdag_common/templates/components/media_preview.html b/artdag_common/templates/components/media_preview.html new file mode 100644 index 0000000..ec810ae --- /dev/null +++ b/artdag_common/templates/components/media_preview.html @@ -0,0 +1,98 @@ +{# +Media preview component for videos, images, and audio. + +Usage: + {% from "components/media_preview.html" import media_preview, video_player, image_preview, audio_player %} + + {{ media_preview(content_hash, media_type, title="Preview") }} + {{ video_player(src="/cache/abc123/mp4", poster="/cache/abc123/thumb") }} +#} + +{% macro media_preview(content_hash, media_type, title=None, class="", show_download=True) %} +
+ {% if title %} +
+

{{ title }}

+
+ {% endif %} + +
+ {% if media_type == "video" %} + {{ video_player("/cache/" + content_hash + "/mp4") }} + {% elif media_type == "image" %} + {{ image_preview("/cache/" + content_hash + "/raw") }} + {% elif media_type == "audio" %} + {{ audio_player("/cache/" + content_hash + "/raw") }} + {% else %} +
+ + + +

Preview not available

+
+ {% endif %} +
+ + {% if show_download %} + + {% endif %} +
+{% endmacro %} + +{% macro video_player(src, poster=None, autoplay=False, muted=True, loop=False, class="") %} + +{% endmacro %} + +{% macro image_preview(src, alt="", class="") %} +{{ alt }} +{% endmacro %} + +{% macro audio_player(src, class="") %} +
+ +
+{% endmacro %} + +{% macro thumbnail(content_hash, media_type, size="w-24 h-24", class="") %} +
+ {% if media_type == "image" %} + + {% elif media_type == "video" %} + + + + {% elif media_type == "audio" %} + + + + {% else %} + + + + {% endif %} +
+{% endmacro %} diff --git a/artdag_common/templates/components/pagination.html b/artdag_common/templates/components/pagination.html new file mode 100644 index 0000000..ec1b4a5 --- /dev/null +++ b/artdag_common/templates/components/pagination.html @@ -0,0 +1,82 @@ +{# +Pagination component with HTMX infinite scroll support. + +Usage: + {% from "components/pagination.html" import infinite_scroll_trigger, page_links %} + + {# Infinite scroll (HTMX) #} + {{ infinite_scroll_trigger(url="/items?page=2", colspan=3, has_more=True) }} + + {# Traditional pagination #} + {{ page_links(current_page=1, total_pages=5, base_url="/items") }} +#} + +{% macro infinite_scroll_trigger(url, colspan=1, has_more=True, target=None) %} +{% if has_more %} + + + + + + + + Loading more... + + + +{% endif %} +{% endmacro %} + +{% macro page_links(current_page, total_pages, base_url, class="") %} + +{% endmacro %} + +{% macro page_info(page, limit, total) %} +
+ Showing {{ (page - 1) * limit + 1 }}-{{ [page * limit, total] | min }} of {{ total }} +
+{% endmacro %} diff --git a/artdag_common/templates/components/table.html b/artdag_common/templates/components/table.html new file mode 100644 index 0000000..1c00fc4 --- /dev/null +++ b/artdag_common/templates/components/table.html @@ -0,0 +1,51 @@ +{# +Table component with dark theme styling. + +Usage: + {% from "components/table.html" import table, table_row %} + + {% call table(columns=["Name", "Status", "Actions"]) %} + {% for item in items %} + {{ table_row([item.name, item.status, actions_html]) }} + {% endfor %} + {% endcall %} +#} + +{% macro table(columns, class="", id="") %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {{ caller() }} + +
{{ col }}
+
+{% endmacro %} + +{% macro table_row(cells, class="", href=None) %} + + {% for cell in cells %} + + {% if href and loop.first %} + {{ cell }} + {% else %} + {{ cell | safe }} + {% endif %} + + {% endfor %} + +{% endmacro %} + +{% macro empty_row(colspan, message="No items found") %} + + + {{ message }} + + +{% endmacro %} diff --git a/artdag_common/utils/__init__.py b/artdag_common/utils/__init__.py new file mode 100644 index 0000000..192edfa --- /dev/null +++ b/artdag_common/utils/__init__.py @@ -0,0 +1,19 @@ +""" +Utility functions shared across Art-DAG servers. +""" + +from .pagination import paginate, get_pagination_params +from .media import detect_media_type, get_media_extension, is_streamable +from .formatting import format_date, format_size, truncate_hash, format_duration + +__all__ = [ + "paginate", + "get_pagination_params", + "detect_media_type", + "get_media_extension", + "is_streamable", + "format_date", + "format_size", + "truncate_hash", + "format_duration", +] diff --git a/artdag_common/utils/formatting.py b/artdag_common/utils/formatting.py new file mode 100644 index 0000000..3dcc3a8 --- /dev/null +++ b/artdag_common/utils/formatting.py @@ -0,0 +1,165 @@ +""" +Formatting utilities for display. +""" + +from datetime import datetime +from typing import Optional, Union + + +def format_date( + value: Optional[Union[str, datetime]], + length: int = 10, + include_time: bool = False, +) -> str: + """ + Format a date/datetime for display. + + Args: + value: Date string or datetime object + length: Length to truncate to (default 10 for YYYY-MM-DD) + include_time: Whether to include time portion + + Returns: + Formatted date string + """ + if value is None: + return "" + + if isinstance(value, str): + # Parse ISO format string + try: + if "T" in value: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + return value[:length] + except ValueError: + return value[:length] + else: + dt = value + + if include_time: + return dt.strftime("%Y-%m-%d %H:%M") + return dt.strftime("%Y-%m-%d") + + +def format_size(size_bytes: Optional[int]) -> str: + """ + Format file size in human-readable form. + + Args: + size_bytes: Size in bytes + + Returns: + Human-readable size string (e.g., "1.5 MB") + """ + if size_bytes is None: + return "Unknown" + if size_bytes < 0: + return "Unknown" + if size_bytes == 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB"] + unit_index = 0 + size = float(size_bytes) + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + return f"{size:.1f} {units[unit_index]}" + + +def truncate_hash(value: str, length: int = 16, suffix: str = "...") -> str: + """ + Truncate a hash or long string with ellipsis. + + Args: + value: String to truncate + length: Maximum length before truncation + suffix: Suffix to add when truncated + + Returns: + Truncated string + """ + if not value: + return "" + if len(value) <= length: + return value + return f"{value[:length]}{suffix}" + + +def format_duration(seconds: Optional[float]) -> str: + """ + Format duration in human-readable form. + + Args: + seconds: Duration in seconds + + Returns: + Human-readable duration string (e.g., "2m 30s") + """ + if seconds is None or seconds < 0: + return "Unknown" + + if seconds < 1: + return f"{int(seconds * 1000)}ms" + + if seconds < 60: + return f"{seconds:.1f}s" + + minutes = int(seconds // 60) + remaining_seconds = int(seconds % 60) + + if minutes < 60: + if remaining_seconds: + return f"{minutes}m {remaining_seconds}s" + return f"{minutes}m" + + hours = minutes // 60 + remaining_minutes = minutes % 60 + + if remaining_minutes: + return f"{hours}h {remaining_minutes}m" + return f"{hours}h" + + +def format_count(count: int) -> str: + """ + Format a count with abbreviation for large numbers. + + Args: + count: Number to format + + Returns: + Formatted string (e.g., "1.2K", "3.5M") + """ + if count < 1000: + return str(count) + if count < 1000000: + return f"{count / 1000:.1f}K" + if count < 1000000000: + return f"{count / 1000000:.1f}M" + return f"{count / 1000000000:.1f}B" + + +def format_percentage(value: float, decimals: int = 1) -> str: + """ + Format a percentage value. + + Args: + value: Percentage value (0-100 or 0-1) + decimals: Number of decimal places + + Returns: + Formatted percentage string + """ + # Assume 0-1 if less than 1 + if value <= 1: + value *= 100 + + if decimals == 0: + return f"{int(value)}%" + return f"{value:.{decimals}f}%" diff --git a/artdag_common/utils/media.py b/artdag_common/utils/media.py new file mode 100644 index 0000000..ef0eaee --- /dev/null +++ b/artdag_common/utils/media.py @@ -0,0 +1,166 @@ +""" +Media type detection and handling utilities. +""" + +from pathlib import Path +from typing import Optional +import mimetypes + +# Initialize mimetypes database +mimetypes.init() + +# Media type categories +VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/avi"} +IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"} +AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac", "audio/mp3"} + +# File extension mappings +EXTENSION_TO_CATEGORY = { + # Video + ".mp4": "video", + ".webm": "video", + ".mov": "video", + ".avi": "video", + ".mkv": "video", + # Image + ".jpg": "image", + ".jpeg": "image", + ".png": "image", + ".gif": "image", + ".webp": "image", + ".svg": "image", + # Audio + ".mp3": "audio", + ".wav": "audio", + ".ogg": "audio", + ".flac": "audio", + ".aac": "audio", + ".m4a": "audio", +} + + +def detect_media_type(path: Path) -> str: + """ + Detect the media category for a file. + + Args: + path: Path to the file + + Returns: + Category string: "video", "image", "audio", or "unknown" + """ + if not path: + return "unknown" + + # Try extension first + ext = path.suffix.lower() + if ext in EXTENSION_TO_CATEGORY: + return EXTENSION_TO_CATEGORY[ext] + + # Try mimetypes + mime_type, _ = mimetypes.guess_type(str(path)) + if mime_type: + if mime_type in VIDEO_TYPES or mime_type.startswith("video/"): + return "video" + if mime_type in IMAGE_TYPES or mime_type.startswith("image/"): + return "image" + if mime_type in AUDIO_TYPES or mime_type.startswith("audio/"): + return "audio" + + return "unknown" + + +def get_mime_type(path: Path) -> str: + """ + Get the MIME type for a file. + + Args: + path: Path to the file + + Returns: + MIME type string or "application/octet-stream" + """ + mime_type, _ = mimetypes.guess_type(str(path)) + return mime_type or "application/octet-stream" + + +def get_media_extension(media_type: str) -> str: + """ + Get the typical file extension for a media type. + + Args: + media_type: Media category or MIME type + + Returns: + File extension with dot (e.g., ".mp4") + """ + if media_type == "video": + return ".mp4" + if media_type == "image": + return ".png" + if media_type == "audio": + return ".mp3" + + # Try as MIME type + ext = mimetypes.guess_extension(media_type) + return ext or "" + + +def is_streamable(path: Path) -> bool: + """ + Check if a file type is streamable (video/audio). + + Args: + path: Path to the file + + Returns: + True if the file can be streamed + """ + media_type = detect_media_type(path) + return media_type in ("video", "audio") + + +def needs_conversion(path: Path, target_format: str = "mp4") -> bool: + """ + Check if a video file needs format conversion. + + Args: + path: Path to the file + target_format: Target format (default mp4) + + Returns: + True if conversion is needed + """ + media_type = detect_media_type(path) + if media_type != "video": + return False + + ext = path.suffix.lower().lstrip(".") + return ext != target_format + + +def get_video_src( + content_hash: str, + original_path: Optional[Path] = None, + is_ios: bool = False, +) -> str: + """ + Get the appropriate video source URL. + + For iOS devices, prefer MP4 format. + + Args: + content_hash: Content hash for the video + original_path: Optional original file path + is_ios: Whether the client is iOS + + Returns: + URL path for the video source + """ + if is_ios: + return f"/cache/{content_hash}/mp4" + + if original_path and original_path.suffix.lower() in (".mp4", ".webm"): + return f"/cache/{content_hash}/raw" + + return f"/cache/{content_hash}/mp4" diff --git a/artdag_common/utils/pagination.py b/artdag_common/utils/pagination.py new file mode 100644 index 0000000..f892f95 --- /dev/null +++ b/artdag_common/utils/pagination.py @@ -0,0 +1,85 @@ +""" +Pagination utilities. +""" + +from typing import List, Any, Tuple, Optional + +from fastapi import Request + +from ..constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE + + +def get_pagination_params(request: Request) -> Tuple[int, int]: + """ + Extract pagination parameters from request query string. + + Args: + request: FastAPI request + + Returns: + Tuple of (page, limit) + """ + try: + page = int(request.query_params.get("page", 1)) + page = max(1, page) + except ValueError: + page = 1 + + try: + limit = int(request.query_params.get("limit", DEFAULT_PAGE_SIZE)) + limit = max(1, min(limit, MAX_PAGE_SIZE)) + except ValueError: + limit = DEFAULT_PAGE_SIZE + + return page, limit + + +def paginate( + items: List[Any], + page: int = 1, + limit: int = DEFAULT_PAGE_SIZE, +) -> Tuple[List[Any], dict]: + """ + Paginate a list of items. + + Args: + items: Full list of items + page: Page number (1-indexed) + limit: Items per page + + Returns: + Tuple of (paginated items, pagination info dict) + """ + total = len(items) + start = (page - 1) * limit + end = start + limit + + paginated = items[start:end] + + return paginated, { + "page": page, + "limit": limit, + "total": total, + "has_more": end < total, + "total_pages": (total + limit - 1) // limit if total > 0 else 1, + } + + +def calculate_offset(page: int, limit: int) -> int: + """Calculate database offset from page and limit.""" + return (page - 1) * limit + + +def build_pagination_info( + page: int, + limit: int, + total: int, +) -> dict: + """Build pagination info dictionary.""" + return { + "page": page, + "limit": limit, + "total": total, + "has_more": page * limit < total, + "total_pages": (total + limit - 1) // limit if total > 0 else 1, + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8205b9b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "artdag-common" +version = "0.1.3" +description = "Shared components for Art-DAG L1 and L2 servers" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.100.0", + "jinja2>=3.1.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["artdag_common"]