From fd97812e3d943bb5b9d445a0835f9b7faef01c03 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 11 Jan 2026 07:07:59 +0000 Subject: [PATCH] Initial artdag-common shared library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared components for L1 and L2 servers: - Jinja2 template system with base template and components - Middleware for auth and content negotiation - Pydantic models for requests/responses - Utility functions for pagination, media, formatting - Constants for Tailwind/HTMX/Cytoscape CDNs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- artdag_common/__init__.py | 18 ++ artdag_common/constants.py | 54 ++++ artdag_common/middleware/__init__.py | 16 ++ artdag_common/middleware/auth.py | 244 ++++++++++++++++++ .../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 | 71 +++++ 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 ++ 21 files changed, 1905 insertions(+) create mode 100644 artdag_common/__init__.py create mode 100644 artdag_common/constants.py create mode 100644 artdag_common/middleware/__init__.py 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/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..6c6e7ca --- /dev/null +++ b/artdag_common/constants.py @@ -0,0 +1,54 @@ +""" +Shared constants for Art-DAG servers. +""" + +# CDN URLs +TAILWIND_CDN = "https://cdn.tailwindcss.com" +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/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/auth.py b/artdag_common/middleware/auth.py new file mode 100644 index 0000000..482e80a --- /dev/null +++ b/artdag_common/middleware/auth.py @@ -0,0 +1,244 @@ +""" +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 + + @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. + + The cookie format is expected to be base64-encoded JSON: + {"username": "user", "actor_id": "@user@server.com"} + + Args: + request: FastAPI request + + Returns: + UserContext if valid cookie found, None otherwise + """ + cookie = request.cookies.get("artdag_session") + if not cookie: + return None + + try: + # Decode base64 cookie + data = json.loads(base64.b64decode(cookie)) + return UserContext( + username=data.get("username", ""), + actor_id=data.get("actor_id", ""), + ) + except (json.JSONDecodeError, ValueError, KeyError): + 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: + return UserContext( + username=claims.get("username", ""), + actor_id=claims.get("actor_id", ""), + 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) + """ + data = json.dumps({ + "username": user.username, + "actor_id": user.actor_id, + }) + 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..5f291af --- /dev/null +++ b/artdag_common/templates/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}Art-DAG{% endblock %} + + + + {{ TAILWIND_CONFIG | safe }} + + + + + {% block head %}{% endblock %} + + + + + {% block 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..38e8927 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "artdag-common" +version = "0.1.0" +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"]