diff --git a/Dockerfile.fly b/Dockerfile.fly index 7f2c3b3..6fa70ff 100644 --- a/Dockerfile.fly +++ b/Dockerfile.fly @@ -7,6 +7,7 @@ FROM public.ecr.aws/docker/library/python:3.12-slim # Install dependencies RUN apt-get update && apt-get install -y \ curl \ + git \ && rm -rf /var/lib/apt/lists/* # Install uv for package management diff --git a/pyproject.toml b/pyproject.toml index b2bdc9a..6f61bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,14 +9,13 @@ dependencies = [ "fastmcp>=2.11.0", "google-genai>=1.0.0", "pillow>=10.0.0", - "fastapi>=0.100.0", "uvicorn>=0.23.0", "httpx>=0.28.1", "pydantic>=2.0.0", "boto3>=1.35.0", "markdown>=3.6", "bleach>=6.3.0", - "adcp>=3.2.0", # Official ADCP Python client with template format support + "adcp>=3.5.0", ] [project.scripts] @@ -32,6 +31,9 @@ packages = ["src/creative_agent"] [tool.uv] package = true +[tool.uv.sources] +adcp = { git = "https://github.com/adcontextprotocol/adcp-client-python", tag = "v3.5.0" } + [dependency-groups] dev = [ "mypy>=1.18.2", diff --git a/src/creative_agent/api_server.py b/src/creative_agent/api_server.py deleted file mode 100644 index 11f470e..0000000 --- a/src/creative_agent/api_server.py +++ /dev/null @@ -1,157 +0,0 @@ -"""FastAPI HTTP server for AdCP Creative Agent (Fly.io deployment).""" - -import os -import uuid -from typing import Any - -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from pydantic import AnyUrl, BaseModel - -from .data.standard_formats import STANDARD_FORMATS, get_format_by_id - -app = FastAPI(title="AdCP Creative Agent", version="1.0.0") - -# Enable CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -class PreviewRequest(BaseModel): - """Request to generate creative preview.""" - - format_id: str - width: int | None = None - height: int | None = None - assets: dict[str, Any] - - -@app.get("/") -async def root() -> dict[str, Any]: - """Root endpoint.""" - return { - "name": "AdCP Creative Agent", - "version": "1.0.0", - "endpoints": { - "formats": "/formats", - "preview": "/preview (POST)", - }, - } - - -@app.get("/health") -async def health() -> dict[str, str]: - """Health check endpoint.""" - return {"status": "ok"} - - -@app.get("/formats") -async def list_formats() -> list[dict[str, Any]]: - """List all available creative formats.""" - return [fmt.model_dump(mode="json", exclude_none=True) for fmt in STANDARD_FORMATS] - - -@app.get("/formats/{format_id}") -async def get_format(format_id: str) -> dict[str, Any]: - """Get a specific format by ID (assumes this agent's formats).""" - - from adcp import FormatId - - from .data.standard_formats import AGENT_URL - - # Convert string ID to FormatId object (assume our agent) - fmt_id = FormatId(agent_url=AnyUrl(AGENT_URL), id=format_id) - fmt = get_format_by_id(fmt_id) - if not fmt: - raise HTTPException(status_code=404, detail=f"Format {format_id} not found") - result: dict[str, Any] = fmt.model_dump(mode="json", exclude_none=True) - return result - - -@app.post("/preview") -async def preview_creative(request: PreviewRequest) -> dict[str, Any]: - """Generate preview from creative manifest.""" - - from adcp import FormatId - - from .data.standard_formats import AGENT_URL - - # Convert string ID to FormatId object (assume our agent) - fmt_id = FormatId(agent_url=AnyUrl(AGENT_URL), id=request.format_id) - fmt = get_format_by_id(fmt_id) - if not fmt: - raise HTTPException(status_code=404, detail=f"Format {request.format_id} not found") - - # Generate preview ID - preview_id = str(uuid.uuid4()) - - # Build iframe HTML based on format type - type_value = fmt.type.value if hasattr(fmt.type, "value") else fmt.type - - if type_value == "display": - # Get dimensions from request or format renders - width = request.width - height = request.height - - if not width or not height: - # Get dimensions from format renders - if fmt.renders and len(fmt.renders) > 0: - render = fmt.renders[0] - if render.dimensions and render.dimensions.width and render.dimensions.height: - width = width or int(render.dimensions.width) - height = height or int(render.dimensions.height) - - # If still missing dimensions, return error - if not width or not height: - raise HTTPException( - status_code=400, - detail=f"Format {fmt_id.id} has no fixed dimensions and dimensions were not provided in request", - ) - - image_url = request.assets.get("image", "") - click_url = request.assets.get("click_url", "#") - - iframe_html = f""" - -""".strip() - - elif type_value == "video": - width = request.width or 640 - height = request.height or 360 - video_url = request.assets.get("video", "") - - iframe_html = f""" - -""".strip() - - else: - # Generic HTML preview - iframe_html = f"
Preview for {fmt.name} (format_id: {request.format_id})
" - - return { - "preview_id": preview_id, - "format_id": request.format_id, - "preview_url": f"https://creative.adcontextprotocol.org/previews/{preview_id}", - "iframe_html": iframe_html, - "manifest": request.model_dump(), - } - - -if __name__ == "__main__": - import uvicorn - - port = int(os.getenv("PORT", "8080")) - uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/src/creative_agent/compat.py b/src/creative_agent/compat.py deleted file mode 100644 index b60c9c3..0000000 --- a/src/creative_agent/compat.py +++ /dev/null @@ -1,65 +0,0 @@ -"""DEPRECATED: Compatibility layer for adcp library types. - -## Deprecation Notice - -As of adcp 2.2.0, semantic type aliases are provided directly by the library. -This module is no longer needed and will be removed in a future version. - -**Instead of:** -```python -from creative_agent.compat import UrlPreviewRender -``` - -**Use:** -```python -from adcp.types import UrlPreviewRender -``` - -## Migration - -All imports from this module should be updated to import directly from `adcp.types`: -- `UrlPreviewRender`, `HtmlPreviewRender`, `BothPreviewRender` -- `UrlVastAsset`, `InlineVastAsset` -- `UrlDaastAsset`, `InlineDaastAsset` -- `MediaSubAsset`, `TextSubAsset` - -This module now re-exports from the library for backward compatibility. -""" - -import warnings - -# Re-export from library for backward compatibility -from adcp.types import ( - BothPreviewRender, - HtmlPreviewRender, - InlineDaastAsset, - InlineVastAsset, - MediaSubAsset, - TextSubAsset, - UrlDaastAsset, - UrlPreviewRender, - UrlVastAsset, -) - -# Emit deprecation warning on import -warnings.warn( - "creative_agent.compat is deprecated. Import type aliases directly from adcp.types instead. " - "This module will be removed in a future version.", - DeprecationWarning, - stacklevel=2, -) - -__all__ = [ - # DAAST assets - "BothPreviewRender", - "HtmlPreviewRender", - "InlineDaastAsset", - "InlineVastAsset", - # SubAssets - "MediaSubAsset", - "TextSubAsset", - # VAST assets - "UrlDaastAsset", - "UrlPreviewRender", - "UrlVastAsset", -] diff --git a/src/creative_agent/data/format_types.py b/src/creative_agent/data/format_types.py index 039d001..f979c7a 100644 --- a/src/creative_agent/data/format_types.py +++ b/src/creative_agent/data/format_types.py @@ -41,7 +41,6 @@ class AssetType(Enum): javascript = "javascript" url = "url" webhook = "webhook" - promoted_offerings = "promoted_offerings" class Unit(Enum): diff --git a/src/creative_agent/data/standard_formats.py b/src/creative_agent/data/standard_formats.py index 808af02..118c403 100644 --- a/src/creative_agent/data/standard_formats.py +++ b/src/creative_agent/data/standard_formats.py @@ -3,10 +3,11 @@ # mypy: disable-error-code="call-arg" # Pydantic models with extra='forbid' trigger false positives when optional fields aren't passed +import typing from typing import Any -from adcp import FormatCategory, FormatId, get_required_assets -from adcp.types.generated_poc.core.format import Assets as LibAssets +from adcp import CatalogRequirements, CatalogType, FormatCategory, FormatId, get_required_assets +from adcp.types.generated_poc.core.format import Format as _LibFormat from adcp.types.generated_poc.core.format import Renders as LibRender from adcp.types.generated_poc.enums.format_id_parameter import FormatIdParameter from pydantic import AnyUrl @@ -16,13 +17,50 @@ AssetType, ) +# Build asset type -> class mappings dynamically from the Format model's discriminated union. +# This avoids hardcoding numbered class names (Assets, Assets5, etc.) which change between +# adcp library versions. +_list_type = typing.get_args(typing.get_args(_LibFormat.model_fields["assets"].annotation)[0]) +_union_members = typing.get_args(_list_type[0]) if _list_type else () + +_INDIVIDUAL_ASSET_MAP: dict[str, type] = {} +_REPEATABLE_GROUP_CLASS: type | None = None +_INNER_ASSET_MAP: dict[str, type] = {} + +for _cls in _union_members: + if not hasattr(_cls, "model_fields"): + continue + if "asset_group_id" in _cls.model_fields: + _REPEATABLE_GROUP_CLASS = _cls + # Build inner asset mapping from the group's assets field + _inner_ann = _cls.model_fields["assets"].annotation + _inner_args = typing.get_args(_inner_ann) + _inner_union = typing.get_args(_inner_args[0]) if _inner_args else () + for _inner_cls in _inner_union: + if hasattr(_inner_cls, "model_fields") and "asset_type" in _inner_cls.model_fields: + _lit = typing.get_args(_inner_cls.model_fields["asset_type"].annotation) + if _lit: + _INNER_ASSET_MAP[_lit[0]] = _inner_cls + continue + at_field = _cls.model_fields.get("asset_type") + if at_field: + _lit = typing.get_args(at_field.annotation) + if _lit: + _INDIVIDUAL_ASSET_MAP[_lit[0]] = _cls + +# Fail fast if the introspection didn't find expected types +if not _INDIVIDUAL_ASSET_MAP: + raise ImportError("No individual asset classes found in adcp Format schema") +if _REPEATABLE_GROUP_CLASS is None: + raise ImportError("No repeatable group class found in adcp Format schema") + # Agent configuration AGENT_URL = "https://creative.adcontextprotocol.org" AGENT_NAME = "AdCP Standard Creative Agent" AGENT_CAPABILITIES = ["validation", "assembly", "generation", "preview"] # Common macros supported across all formats -COMMON_MACROS = [ +COMMON_MACROS: list[str | Any] = [ "MEDIA_BUY_ID", "CREATIVE_ID", "CACHEBUSTER", @@ -44,28 +82,67 @@ def create_asset( asset_id: str, asset_type: AssetType, required: bool = True, - requirements: dict[str, str | int | float | bool | list[str]] | None = None, -) -> LibAssets: - """Create an asset entry using the library's Assets Pydantic model. + requirements: Any = None, +) -> Any: + """Create an individual asset using the correct typed model for the given asset_type. - This creates assets for the new 'assets' field (adcp-client-python 2.18.0+). - The library model automatically handles exclude_none serialization and - includes the item_type discriminator for union types. + The adcp library uses a discriminated union for assets — each asset_type has its own + Pydantic model with type-specific requirements. This function resolves the correct + class dynamically from the Format schema. """ - from adcp import AssetContentType as LibAssetType - - lib_asset_type = LibAssetType(asset_type.value) + cls = _INDIVIDUAL_ASSET_MAP.get(asset_type.value) + if cls is None: + raise ValueError(f"Unknown asset type: {asset_type.value}") - return LibAssets( + return cls( asset_id=asset_id, - asset_type=lib_asset_type, + asset_type=asset_type.value, required=required, requirements=requirements, - item_type="individual", # Required discriminator for union types + item_type="individual", + ) + + +def create_repeatable_group( + asset_group_id: str, + asset_type: AssetType, + required: bool, + min_count: int, + max_count: int, + requirements: Any = None, +) -> Any: + """Create a repeatable group asset that allows multiple values of the same type. + + Used for asset pools like headlines (up to 15) or images (up to 20), where + publishers pick the best combination for each placement. + + Each group instance contains a single inner asset whose asset_id matches the + content type string (e.g. "text", "image", "video"). + """ + if _REPEATABLE_GROUP_CLASS is None: + raise RuntimeError("Repeatable group class not found in adcp format schema") + + inner_cls = _INNER_ASSET_MAP.get(asset_type.value) + if inner_cls is None: + raise ValueError(f"Unknown inner asset type: {asset_type.value}") + + inner = inner_cls( + asset_id=asset_type.value, + asset_type=asset_type.value, + required=True, + requirements=requirements, + ) + return _REPEATABLE_GROUP_CLASS( + asset_group_id=asset_group_id, + assets=[inner], + item_type="repeatable_group", + min_count=min_count, + max_count=max_count, + required=required, ) -def create_impression_tracker_asset() -> LibAssets: +def create_impression_tracker_asset() -> Any: """Create an optional impression tracker asset for 3rd party tracking. This creates a URL asset with url_type='tracker_pixel' that can be used @@ -82,7 +159,7 @@ def create_impression_tracker_asset() -> LibAssets: ) -def create_click_url_asset() -> LibAssets: +def create_click_url_asset() -> Any: """Create a required clickthrough URL asset. This creates a URL asset with url_type='clickthrough' for the landing page @@ -146,9 +223,9 @@ def create_responsive_render( # Generative Formats - AI-powered creative generation -# These use promoted_offerings asset type which provides brand context and product info -# Template format that accepts dimension parameters (for new integrations) -# Plus concrete formats (for backward compatibility) +# Generative formats use catalog_requirements to declare what offering data they need. +# The AI generates creative from the catalog context + a generation prompt. +# Template format accepts dimension parameters; concrete formats are for backward compatibility. GENERATIVE_FORMATS = [ # Template format - supports any dimensions CreativeFormat( @@ -158,13 +235,13 @@ def create_responsive_render( description="AI-generated display banner from brand context and prompt (supports any dimensions)", accepts_parameters=[FormatIdParameter.dimensions], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -183,13 +260,13 @@ def create_responsive_render( renders=[create_fixed_render(300, 250)], output_format_ids=[create_format_id("display_300x250_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -207,13 +284,13 @@ def create_responsive_render( renders=[create_fixed_render(728, 90)], output_format_ids=[create_format_id("display_728x90_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -231,13 +308,13 @@ def create_responsive_render( renders=[create_fixed_render(320, 50)], output_format_ids=[create_format_id("display_320x50_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -255,13 +332,13 @@ def create_responsive_render( renders=[create_fixed_render(160, 600)], output_format_ids=[create_format_id("display_160x600_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -279,13 +356,13 @@ def create_responsive_render( renders=[create_fixed_render(336, 280)], output_format_ids=[create_format_id("display_336x280_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -303,13 +380,13 @@ def create_responsive_render( renders=[create_fixed_render(300, 600)], output_format_ids=[create_format_id("display_300x600_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -327,13 +404,13 @@ def create_responsive_render( renders=[create_fixed_render(970, 250)], output_format_ids=[create_format_id("display_970x250_image")], supported_macros=COMMON_MACROS, - assets=[ - create_asset( - asset_id="promoted_offerings", - asset_type=AssetType.promoted_offerings, - required=True, - requirements={"description": "Brand manifest and product offerings for AI generation"}, + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.offering, + required_fields=["name"], ), + ], + assets=[ create_asset( asset_id="generation_prompt", asset_type=AssetType.text, @@ -1489,7 +1566,7 @@ def matches_format_id(fmt: CreativeFormat, search_id: tuple[str, str, int | None # fmt.type is always a Type enum (adcp 2.1.0+) if isinstance(type, str): # Compare enum value to string - results = [fmt for fmt in results if fmt.type.value == type] + results = [fmt for fmt in results if fmt.type is not None and fmt.type.value == type] else: # Compare enum to enum results = [fmt for fmt in results if fmt.type == type] @@ -1589,18 +1666,17 @@ def has_asset_type(req: Any, target_type: AssetType | str) -> bool: # Compare string values target_str = target_type.value if isinstance(target_type, AssetType) else target_type - # assets_required are always Pydantic models (adcp 2.2.0+) - req_asset_type = req.asset_type - # Handle enum type - if hasattr(req_asset_type, "value"): - req_asset_type = req_asset_type.value - if req_asset_type == target_str: - return True - # Check if it's a grouped asset requirement with assets array + # Individual assets have asset_type directly; repeatable groups do not + req_asset_type = getattr(req, "asset_type", None) + if req_asset_type is not None: + if hasattr(req_asset_type, "value"): + req_asset_type = req_asset_type.value + if req_asset_type == target_str: + return True + # Repeatable groups carry inner assets — check those if hasattr(req, "assets"): for asset in req.assets: asset_type: Any = getattr(asset, "asset_type", None) - # Handle enum type if asset_type is not None and hasattr(asset_type, "value"): asset_type = asset_type.value if asset_type == target_str: diff --git a/src/creative_agent/http_server.py b/src/creative_agent/http_server.py deleted file mode 100644 index 4222f08..0000000 --- a/src/creative_agent/http_server.py +++ /dev/null @@ -1,12 +0,0 @@ -"""HTTP server for AdCP Creative Agent (for Fly.io deployment).""" - -import os - -from .server import mcp - -# Get port from environment (Fly.io sets this) -PORT = int(os.getenv("PORT", "8080")) - -if __name__ == "__main__": - # Run in HTTP mode for production deployment - mcp.run(transport="sse", port=PORT, host="0.0.0.0") diff --git a/src/creative_agent/schemas/__init__.py b/src/creative_agent/schemas/__init__.py index ba0e37f..7c30a20 100644 --- a/src/creative_agent/schemas/__init__.py +++ b/src/creative_agent/schemas/__init__.py @@ -12,20 +12,7 @@ from adcp import CreativeManifest, ListCreativeFormatsResponse from adcp import Format as CreativeFormat -# Build schemas (agent-specific, not part of AdCP) -from .build import ( - AssetReference, - BuildCreativeRequest, - BuildCreativeResponse, - CreativeOutput, - PreviewContext, - PreviewOptions, -) - -# Format helpers (agent-specific, not part of AdCP) -from .format_helpers import AssetRequirement, FormatRequirements - -# Manifest/Preview schemas - these need manual definitions as they're agent-specific +# Manifest/Preview schemas - agent-specific definitions from .manifest import ( PreviewCreativeRequest, PreviewCreativeResponse, @@ -36,21 +23,13 @@ ) __all__ = [ - "AssetReference", - "AssetRequirement", - "BuildCreativeRequest", - "BuildCreativeResponse", "CreativeFormat", "CreativeManifest", - "CreativeOutput", - "FormatRequirements", "ListCreativeFormatsResponse", - "PreviewContext", "PreviewCreativeRequest", "PreviewCreativeResponse", "PreviewEmbedding", "PreviewHints", "PreviewInput", - "PreviewOptions", "PreviewVariant", ] diff --git a/src/creative_agent/schemas/brand_card.py b/src/creative_agent/schemas/brand_card.py deleted file mode 100644 index bbe4e06..0000000 --- a/src/creative_agent/schemas/brand_card.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Brand card schema for AdCP creative agent.""" - -from pydantic import BaseModel, HttpUrl - - -class BrandAsset(BaseModel): - """Brand asset with URL and metadata.""" - - asset_id: str - asset_type: str # "image", "video", "logo", etc. - url: HttpUrl - tags: list[str] = [] - width: int | None = None - height: int | None = None - description: str | None = None - - -class BrandCard(BaseModel): - """Brand card containing brand identity and assets.""" - - brand_url: HttpUrl - brand_name: str | None = None - brand_description: str | None = None - assets: list[BrandAsset] = [] - product_catalog_url: HttpUrl | None = None - primary_color: str | None = None - secondary_color: str | None = None diff --git a/src/creative_agent/schemas/build.py b/src/creative_agent/schemas/build.py deleted file mode 100644 index f7464c5..0000000 --- a/src/creative_agent/schemas/build.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Build creative schemas for AdCP.""" - -from typing import Any, Literal - -from pydantic import BaseModel, HttpUrl - - -class AssetReference(BaseModel): - """Reference to an asset library.""" - - library_id: str - asset_ids: list[str] | None = None - tags: list[str] | None = None - filters: dict[str, Any] | None = None - - -class PreviewContext(BaseModel): - """Context for preview generation.""" - - name: str - user_data: dict[str, Any] - - -class PreviewOptions(BaseModel): - """Preview generation options.""" - - contexts: list[PreviewContext] | None = None - template_id: str | None = None - - -class BuildCreativeRequest(BaseModel): - """Request to build a creative (AdCP spec).""" - - message: str # Creative brief or refinement instructions - target_format_id: str - format_source: HttpUrl | None = None - output_mode: Literal["manifest", "code"] = "manifest" - context_id: str | None = None # For iterative refinement - assets: list[AssetReference] = [] - preview_options: PreviewOptions | None = None - finalize: bool = False - - -class CreativeOutput(BaseModel): - """Creative output (manifest or code).""" - - type: Literal["creative_manifest", "creative_code"] - format_id: str - output_format_ids: list[str] | None = None # For generative formats: the output format IDs produced - data: dict[str, Any] # Manifest structure or code - - -class BuildCreativeResponse(BaseModel): - """Response from build_creative (AdCP spec).""" - - message: str # Agent's description - context_id: str - status: Literal["draft", "ready", "finalized"] - creative_output: CreativeOutput - preview: HttpUrl | None = None - refinement_suggestions: list[str] = [] diff --git a/src/creative_agent/schemas/format_helpers.py b/src/creative_agent/schemas/format_helpers.py deleted file mode 100644 index 7de384c..0000000 --- a/src/creative_agent/schemas/format_helpers.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Helper classes for defining creative formats (not part of AdCP spec).""" - -from pydantic import BaseModel - - -class AssetRequirement(BaseModel): - """Requirement for a single asset in a format.""" - - asset_role: str # e.g., "hero_image", "headline" - asset_type: str # image, video, audio, text, etc. - required: bool = True - width: int | None = None - height: int | None = None - duration_seconds: int | None = None - max_file_size_mb: float | None = None - acceptable_formats: list[str] | None = None - description: str | None = None - - -class FormatRequirements(BaseModel): - """Technical requirements for a format.""" - - duration_seconds: int | None = None - max_file_size_mb: float | None = None - acceptable_formats: list[str] | None = None - aspect_ratios: list[str] | None = None - min_bitrate_kbps: int | None = None - max_bitrate_kbps: int | None = None diff --git a/src/creative_agent/schemas/library.py b/src/creative_agent/schemas/library.py deleted file mode 100644 index bd3b5f2..0000000 --- a/src/creative_agent/schemas/library.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Manage creative library schemas for AdCP.""" - -from typing import Any, Literal - -from pydantic import BaseModel, HttpUrl - - -class AssetMetadata(BaseModel): - """Asset metadata.""" - - width: int | None = None - height: int | None = None - duration: float | None = None # seconds - file_size: int | None = None # bytes - mime_type: str | None = None - alt_text: str | None = None - - -class Asset(BaseModel): - """Creative asset.""" - - asset_id: str | None = None # Auto-generated if not provided - name: str - type: Literal["image", "video", "audio", "text", "logo"] - url: HttpUrl | None = None - content: str | None = None # For text assets - metadata: AssetMetadata | None = None - tags: list[str] = [] - usage_rights: Literal["unlimited", "limited", "exclusive"] | None = None - expires_at: str | None = None # ISO date - - -class SearchFilters(BaseModel): - """Filters for asset search.""" - - type: str | None = None - tags: list[str] = [] - usage_rights: str | None = None - - -class ManageCreativeLibraryRequest(BaseModel): - """Request to manage creative library (AdCP spec).""" - - action: Literal["add", "update", "remove", "list", "search"] - library_id: str - asset: Asset | None = None # Required for add/update - assets: list[Asset] | None = None # For bulk add - asset_id: str | None = None # Required for update/remove - tags: list[str] = [] # For filtering/updating - search_query: str | None = None - filters: SearchFilters | None = None - - -class ManageCreativeLibraryResponse(BaseModel): - """Response from manage_creative_library (AdCP spec).""" - - message: str - success: bool - result: Any # Varies by action: asset, list of assets, etc. diff --git a/src/creative_agent/schemas/manifest.py b/src/creative_agent/schemas/manifest.py index 8e8c9e8..65743a2 100644 --- a/src/creative_agent/schemas/manifest.py +++ b/src/creative_agent/schemas/manifest.py @@ -21,7 +21,7 @@ class PreviewCreativeRequest(BaseModel): """Request for preview_creative task.""" format_id: FormatId - creative_manifest: dict[str, Any] # AdCP CreativeAsset structure (including promoted_offerings if required) + creative_manifest: dict[str, Any] # AdCP CreativeAsset structure (assets, catalogs, etc.) inputs: list[PreviewInput] | None = None template_id: str | None = None diff --git a/src/creative_agent/schemas/preview.py b/src/creative_agent/schemas/preview.py deleted file mode 100644 index 556107a..0000000 --- a/src/creative_agent/schemas/preview.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Preview creative schemas for AdCP.""" - -from pydantic import BaseModel, HttpUrl - -from .brand_card import BrandCard - - -class PreviewVariant(BaseModel): - """Request for a specific preview variant.""" - - variant_id: str - device_type: str | None = None # "desktop", "mobile", "tablet" - country: str | None = None - region: str | None = None - context_description: str | None = None - macro_values: dict[str, str] = {} - - -class PreviewCreativeRequest(BaseModel): - """Request to preview a creative with variants.""" - - format_id: str - brand_card: BrandCard - promoted_products: list[str] = [] # SKUs or product IDs - creative_html: str | None = None # If providing existing creative - creative_url: HttpUrl | None = None # If creative is hosted - variants: list[PreviewVariant] = [] - ai_api_key: str | None = None # For AI-generated previews - - -class PreviewResponse(BaseModel): - """Single preview response.""" - - variant_id: str - preview_url: HttpUrl - preview_html: str | None = None - screenshot_url: HttpUrl | None = None - macros_applied: dict[str, str] = {} - - -class PreviewCreativeResponse(BaseModel): - """Response with multiple preview variants.""" - - format_id: str - previews: list[PreviewResponse] diff --git a/src/creative_agent/server.py b/src/creative_agent/server.py index 7921541..fac2285 100644 --- a/src/creative_agent/server.py +++ b/src/creative_agent/server.py @@ -1,7 +1,9 @@ """AdCP Creative Agent MCP Server - Spec Compliant Implementation.""" import json +import logging import os +import re import uuid from datetime import UTC, datetime, timedelta from typing import Any @@ -27,6 +29,8 @@ PreviewCreativeRequest, ) +logger = logging.getLogger(__name__) + mcp = FastMCP("adcp-creative-agent") @@ -77,10 +81,13 @@ def _format_to_human_readable(fmt: Any) -> str: macros = fmt.supported_macros or [] macro_count_str = f"{len(macros)} supported macros" if macros else "no macros" - # Extract assets info using adcp 2.18.0 utilities - # Filter to individual assets (Assets) which have asset_id, skip repeatable groups (Assets1) - required_assets = [a.asset_id for a in get_required_assets(fmt) if hasattr(a, "asset_id")] - optional_assets = [a.asset_id for a in get_optional_assets(fmt) if hasattr(a, "asset_id")] + # Extract assets info using adcp utilities — handles both individual (asset_id) and repeatable groups (asset_group_id) + required_assets: list[str] = [ + x for a in get_required_assets(fmt) if (x := getattr(a, "asset_id", None) or getattr(a, "asset_group_id", None)) + ] + optional_assets: list[str] = [ + x for a in get_optional_assets(fmt) if (x := getattr(a, "asset_id", None) or getattr(a, "asset_group_id", None)) + ] asset_req_str = ", ".join(required_assets[:5]) if len(required_assets) > 5: @@ -188,9 +195,12 @@ def list_creative_formats( response_json = response.model_dump(mode="json", exclude_none=True) # Add assets_required for backward compatibility with 2.5.x clients + # Only include individual assets (asset_id present); repeatable groups are not understood by old clients for fmt_json in response_json.get("formats", []): if fmt_json.get("assets"): - fmt_json["assets_required"] = [asset for asset in fmt_json["assets"] if asset.get("required", False)] + fmt_json["assets_required"] = [ + asset for asset in fmt_json["assets"] if asset.get("required", False) and "asset_id" in asset + ] if formats: format_details = [_format_to_human_readable(fmt) for fmt in formats] @@ -209,9 +219,8 @@ def list_creative_formats( structured_content=error_response, ) except Exception as e: - import traceback - - error_response = {"error": f"Server error: {e}", "traceback": traceback.format_exc()[-500:]} + logger.exception("Server error in list_creative_formats") + error_response = {"error": f"Server error: {e}"} return ToolResult( content=[TextContent(type="text", text=f"Error: Server error - {e}")], structured_content=error_response, @@ -244,6 +253,16 @@ def preview_creative( Returns: ToolResult with human-readable message and structured preview data """ + valid_output_formats = {"url", "html"} + if output_format not in valid_output_formats: + error_msg = ( + f"Invalid output_format '{output_format}'. Must be one of: {', '.join(sorted(valid_output_formats))}" + ) + return ToolResult( + content=[TextContent(type="text", text=f"Error: {error_msg}")], + structured_content={"error": error_msg}, + ) + try: # Determine mode: batch or single is_batch_mode = requests is not None @@ -272,12 +291,11 @@ def preview_creative( structured_content={"error": error_msg}, ) except Exception as e: - import traceback - + logger.exception("Preview generation failed") error_msg = f"Preview generation failed: {e}" return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg, "traceback": traceback.format_exc()[-500:]}, + structured_content={"error": error_msg}, ) @@ -393,17 +411,7 @@ def _handle_single_preview( # Calculate expiration expires_at = datetime.now(UTC) + timedelta(hours=24) - from pydantic import ValidationError - - # Prepare response - validation happens when creating PreviewCreativeResponse - try: - interactive_url = f"{AGENT_URL}/preview/{preview_id}/interactive" - except ValidationError as e: - error_msg = f"Invalid URL construction: {e}" - return ToolResult( - content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={"error": error_msg}, - ) + interactive_url = f"{AGENT_URL}/preview/{preview_id}/interactive" # Build response dict for single mode response_dict = { @@ -430,7 +438,7 @@ def _handle_batch_preview( ) -> ToolResult: """Handle batch preview requests.""" - if not requests or len(requests) == 0: + if not requests: error_msg = "Batch mode requires at least one request" return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], @@ -567,7 +575,7 @@ def _generate_preview_variant( embedding = { "recommended_sandbox": "allow-scripts allow-same-origin", "requires_https": False, - "supports_fullscreen": format_obj.type in ["video", "rich_media"], + "supports_fullscreen": format_obj.type is not None and format_obj.type.value in ("video", "rich_media"), } # Create the single render (all formats render as HTML pages) @@ -618,13 +626,15 @@ def build_creative( target_format_id: str | dict[str, Any], creative_manifest: dict[str, Any] | None = None, message: str | None = None, + brand: str | dict[str, Any] | None = None, ) -> ToolResult: """Transform or generate a creative manifest using AI. Args: target_format_id: Format ID to generate (string or FormatId object with agent_url and id) - creative_manifest: Source creative manifest with input assets (e.g., promoted_offerings for generative formats) + creative_manifest: Source creative manifest with input assets and catalogs for generative formats message: Natural language instructions for transformation or generation + brand: Brand reference — domain string (e.g. "acme.com") or {"domain": "acme.com", "brand_id": "..."} Returns: ToolResult with creative_manifest in structured_content @@ -688,8 +698,6 @@ def build_creative( # Extract input assets from manifest input_assets = creative_manifest.get("assets", {}) - # Extract promoted_offerings if present - promoted_offerings = input_assets.get("promoted_offerings") generation_prompt_asset = input_assets.get("generation_prompt") # Build generation prompt @@ -710,7 +718,7 @@ def build_creative( # Build prompt format_spec = f"""Format: {output_fmt.name} -Type: {output_fmt.type.value} +Type: {output_fmt.type.value if output_fmt.type else "unknown"} Description: {output_fmt.description} """ @@ -727,17 +735,27 @@ def build_creative( if asset_id: format_spec += f"- {asset_id} ({asset_type})\n" - # Add brand context if provided + # Build context from brand reference and catalog brand_context = "" - if promoted_offerings: - brand_context = "\n\nBrand Context:\n" - brand_manifest = promoted_offerings.get("brand_manifest", {}) - if "name" in brand_manifest: - brand_context += f"Brand: {brand_manifest['name']}\n" - if "description" in brand_manifest: - brand_context += f"Description: {brand_manifest['description']}\n" - if "tagline" in brand_manifest: - brand_context += f"Tagline: {brand_manifest['tagline']}\n" + if brand: + brand_domain = brand if isinstance(brand, str) else brand.get("domain", "") + if brand_domain: + brand_context += f"\n\nBrand: {brand_domain}\n" + + catalogs = creative_manifest.get("catalogs", []) if creative_manifest else [] + if catalogs and isinstance(catalogs, list): + catalog = catalogs[0] + if isinstance(catalog, dict): + items = catalog.get("items", []) + if items and isinstance(items, list): + brand_context += "\n\nCatalog Context:\n" if not brand_context else "\nCatalog Items:\n" + for item in items[:3]: + if isinstance(item, dict): + if "name" in item: + brand_context += f"- {item['name']}" + if "description" in item: + brand_context += f": {item['description']}" + brand_context += "\n" prompt = f"""You are a creative generation AI for advertising. Generate a creative manifest for the following request: @@ -770,8 +788,6 @@ def build_creative( generated_text += part.text # Extract JSON from response - import re - json_match = re.search(r"```json\s*(.*?)\s*```", generated_text, re.DOTALL) if json_match: manifest_json = json_match.group(1) @@ -823,15 +839,11 @@ def build_creative( structured_content={"error": error_msg}, ) except Exception as e: - import traceback - + logger.exception("Creative generation failed") error_msg = f"Creative generation failed: {e}" return ToolResult( content=[TextContent(type="text", text=f"Error: {error_msg}")], - structured_content={ - "error": error_msg, - "traceback": traceback.format_exc()[-500:], - }, + structured_content={"error": error_msg}, ) @@ -883,9 +895,8 @@ def get_adcp_capabilities( structured_content=error_response, ) except Exception as e: - import traceback - - error_response = {"error": f"Server error: {e}", "traceback": traceback.format_exc()[-500:]} + logger.exception("Server error in get_adcp_capabilities") + error_response = {"error": f"Server error: {e}"} return ToolResult( content=[TextContent(type="text", text=f"Error: Server error - {e}")], structured_content=error_response, diff --git a/src/creative_agent/validation.py b/src/creative_agent/validation.py index a12405b..9a0dd79 100644 --- a/src/creative_agent/validation.py +++ b/src/creative_agent/validation.py @@ -112,15 +112,13 @@ def validate_url(url: str) -> None: try: parsed = urlparse(url) if not parsed.scheme or not parsed.netloc: - # Allow data URIs for images - if url_lower.startswith("data:image/"): - validate_data_uri(url) - return raise AssetValidationError("URL must have scheme and host") if parsed.scheme not in ["http", "https"]: raise AssetValidationError(f"URL scheme must be http or https, got: {parsed.scheme}") + except AssetValidationError: + raise except Exception as e: raise AssetValidationError(f"Invalid URL format: {e}") from e @@ -150,9 +148,9 @@ def validate_data_uri(uri: str) -> None: if not any(mime_part == mime for mime in allowed_image_mimes): raise AssetValidationError(f"Data URI MIME type not allowed: {mime_part}") - # Check size (limit to 10MB for data URIs) + # Check size (limit to ~10MB of base64-encoded data) if len(data) > 10 * 1024 * 1024: - raise AssetValidationError("Data URI exceeds 10MB size limit") + raise AssetValidationError("Data URI exceeds size limit") def validate_image_url(url: str, check_mime: bool = False) -> None: @@ -316,45 +314,173 @@ def validate_asset(asset_data: dict[str, Any], asset_type: str, check_remote_mim raise AssetValidationError("Webhook url must be a string") validate_url(url) - elif asset_type == "promoted_offerings": - # Promoted offerings validation - used for generative creative formats - # Contains brand manifest and product selectors - # Per spec: can be inline object OR URL reference - - # Check if there's a brand_manifest field (can be URL or object) - brand_manifest = asset_data.get("brand_manifest") - if brand_manifest: - if isinstance(brand_manifest, str): - # URL reference to hosted manifest - validate_url(brand_manifest) - elif isinstance(brand_manifest, dict): - # Inline brand manifest - must have url OR name - url = brand_manifest.get("url") - name = brand_manifest.get("name") - if not url and not name: - raise AssetValidationError("Inline brand manifest must have either url or name") - if url: - if not isinstance(url, str): - raise AssetValidationError("Brand manifest url must be a string") - validate_url(url) - if name and not isinstance(name, str): - raise AssetValidationError("Brand manifest name must be a string") - else: - raise AssetValidationError("brand_manifest must be a URL string or object") - else: raise AssetValidationError(f"Unknown asset_type: {asset_type}") +def validate_catalog( + catalog_data: dict[str, Any], + catalog_requirements: dict[str, Any] | None = None, + check_remote_mime: bool = False, +) -> list[str]: + """Validate a catalog entry against optional requirements. + + Args: + catalog_data: Catalog dictionary with type, items, url, etc. + catalog_requirements: CatalogRequirements dict to validate against (optional) + check_remote_mime: If True, verify MIME types for remote URLs + + Returns: + List of validation error messages (empty if all valid) + """ + errors: list[str] = [] + + # type is required on Catalog + catalog_type = catalog_data.get("type") + if not catalog_type: + errors.append("Catalog must have a 'type' field") + return errors + + from adcp import CatalogType + + valid_types = {ct.value for ct in CatalogType} + if catalog_type not in valid_types: + errors.append(f"Invalid catalog type: '{catalog_type}' (valid: {', '.join(sorted(valid_types))})") + + # Validate URL if provided + catalog_url = catalog_data.get("url") + if catalog_url: + if not isinstance(catalog_url, str): + errors.append("Catalog url must be a string") + else: + try: + validate_url(catalog_url) + except AssetValidationError as e: + errors.append(f"Catalog url: {e}") + + # Validate optional enum fields + feed_format = catalog_data.get("feed_format") + if feed_format: + from adcp import FeedFormat + + valid_feeds = {ff.value for ff in FeedFormat} + if feed_format not in valid_feeds: + errors.append(f"Invalid feed_format: '{feed_format}' (valid: {', '.join(sorted(valid_feeds))})") + + content_id_type = catalog_data.get("content_id_type") + if content_id_type: + from adcp import ContentIdType + + valid_cid = {c.value for c in ContentIdType} + if content_id_type not in valid_cid: + errors.append(f"Invalid content_id_type: '{content_id_type}' (valid: {', '.join(sorted(valid_cid))})") + + update_frequency = catalog_data.get("update_frequency") + if update_frequency: + from adcp import UpdateFrequency + + valid_uf = {u.value for u in UpdateFrequency} + if update_frequency not in valid_uf: + errors.append(f"Invalid update_frequency: '{update_frequency}' (valid: {', '.join(sorted(valid_uf))})") + + conversion_events = catalog_data.get("conversion_events") + if conversion_events is not None: + if not isinstance(conversion_events, list): + errors.append("conversion_events must be a list") + else: + from adcp.types.generated_poc.enums.event_type import EventType + + valid_events = {e.value for e in EventType} + for i, evt in enumerate(conversion_events): + if evt not in valid_events: + errors.append(f"Invalid conversion_event[{i}]: '{evt}'") + + # Validate items if present + items = catalog_data.get("items") + if items is not None: + if not isinstance(items, list): + errors.append("Catalog items must be a list") + elif catalog_requirements: + # Check required_fields on each item + required_fields = catalog_requirements.get("required_fields", []) or [] + for i, item in enumerate(items): + if not isinstance(item, dict): + errors.append(f"Catalog item[{i}] must be a dictionary") + continue + for field in required_fields: + if field not in item: + errors.append(f"Catalog item[{i}]: missing required field '{field}'") + + # Validate against requirements + if catalog_requirements: + # min_items constraint + min_items = catalog_requirements.get("min_items") + if min_items is not None and items is not None and isinstance(items, list): + if len(items) < min_items: + errors.append(f"Catalog requires at least {min_items} items, got {len(items)}") + + # offering_asset_constraints: validate item-level assets + constraints = catalog_requirements.get("offering_asset_constraints", []) or [] + if constraints and items and isinstance(items, list): + for i, item in enumerate(items): + if not isinstance(item, dict): + continue + item_assets = item.get("assets", {}) + if not isinstance(item_assets, dict): + continue + + for constraint in constraints: + group_id = constraint.get("asset_group_id") + if not group_id: + continue + + is_required = constraint.get("required", True) + group_data = item_assets.get(group_id) + + if group_data is None: + if is_required: + errors.append(f"Catalog item[{i}]: missing required asset group '{group_id}'") + continue + + if not isinstance(group_data, list): + group_data = [group_data] + + min_count = constraint.get("min_count") + max_count = constraint.get("max_count") + if min_count is not None and len(group_data) < min_count: + errors.append( + f"Catalog item[{i}] asset '{group_id}': requires at least {min_count} items, got {len(group_data)}" + ) + if max_count is not None and len(group_data) > max_count: + errors.append( + f"Catalog item[{i}] asset '{group_id}': allows at most {max_count} items, got {len(group_data)}" + ) + + # Validate each item against the constraint's asset_type + constraint_type = constraint.get("asset_type") + if constraint_type: + ct_str = constraint_type.value if hasattr(constraint_type, "value") else str(constraint_type) + for j, asset_item in enumerate(group_data): + if not isinstance(asset_item, dict): + errors.append(f"Catalog item[{i}] asset '{group_id}[{j}]': must be a dictionary") + continue + try: + validate_asset(asset_item, ct_str, check_remote_mime=check_remote_mime) + except AssetValidationError as e: + errors.append(f"Catalog item[{i}] asset '{group_id}[{j}]': {e}") + + return errors + + def validate_manifest_assets( manifest: Any, check_remote_mime: bool = False, format_obj: Any = None, ) -> list[str]: - """Validate all assets in a creative manifest. + """Validate all assets and catalogs in a creative manifest. Args: - manifest: Creative manifest (should be dictionary with assets field) + manifest: Creative manifest (should be dictionary with assets and/or catalogs field) check_remote_mime: If True, verify MIME types for remote URLs (slower) format_obj: Format object to validate required assets against (optional) @@ -366,62 +492,161 @@ def validate_manifest_assets( if not isinstance(manifest, dict): return ["Manifest must be a dictionary"] + # Check whether format requires catalogs + has_catalog_requirements = False + catalog_req_list: list[dict[str, Any]] = [] + if format_obj: + raw_reqs = getattr(format_obj, "catalog_requirements", None) or [] + for req in raw_reqs: + req_dict = req.model_dump(exclude_none=True) if hasattr(req, "model_dump") else dict(req) + catalog_req_list.append(req_dict) + has_catalog_requirements = len(catalog_req_list) > 0 + + # Validate catalogs if format requires them + if has_catalog_requirements: + catalogs = manifest.get("catalogs", []) + if not catalogs: + for req in catalog_req_list: + is_required = req.get("required", True) + if is_required is not False: + errors.append(f"Required catalog missing for type: {req.get('catalog_type', 'unknown')}") + elif isinstance(catalogs, list): + # Build lookup of catalogs by type + catalog_by_type: dict[str, dict[str, Any]] = {} + for catalog in catalogs: + if isinstance(catalog, dict) and "type" in catalog: + catalog_by_type[catalog["type"]] = catalog + + for req in catalog_req_list: + req_type = req.get("catalog_type") + if not req_type: + continue + req_type_str = req_type.value if hasattr(req_type, "value") else str(req_type) + is_required = req.get("required", True) + + matching_catalog = catalog_by_type.get(req_type_str) + if not matching_catalog: + if is_required is not False: + errors.append(f"Required catalog missing for type: {req_type_str}") + continue + + catalog_errors = validate_catalog(matching_catalog, req, check_remote_mime=check_remote_mime) + errors.extend(catalog_errors) + + # Check feed_format compatibility + req_feed_formats = req.get("feed_formats") + catalog_feed = matching_catalog.get("feed_format") + if req_feed_formats and catalog_feed: + allowed = [ff.value if hasattr(ff, "value") else str(ff) for ff in req_feed_formats] + if catalog_feed not in allowed: + errors.append(f"Catalog feed_format '{catalog_feed}' not in required formats: {allowed}") + + # Validate any catalogs not covered by requirements + req_types: set[str] = set() + for r in catalog_req_list: + ct = r.get("catalog_type") + if ct is not None: + req_types.add(ct.value if hasattr(ct, "value") else str(ct)) + for catalog in catalogs: + if isinstance(catalog, dict): + ct = catalog.get("type") + if ct and ct not in req_types: + catalog_errors = validate_catalog(catalog, check_remote_mime=check_remote_mime) + errors.extend(catalog_errors) + assets = manifest.get("assets") - if not assets: + if not assets and not has_catalog_requirements: return ["Manifest must contain assets field"] + if not assets: + return errors + if not isinstance(assets, dict): - return ["Manifest assets must be a dictionary"] + errors.append("Manifest assets must be a dictionary") + return errors + + # Build maps from format spec if provided: + # asset_type_map: (asset_id or asset_group_id) -> content type string + # group_counts: asset_group_id -> (min_count, max_count) + asset_type_map: dict[str, str] = {} + group_counts: dict[str, tuple[int | None, int | None]] = {} - # Build a map of asset_id -> asset_type from format if provided - # Uses adcp utilities which handle both new `assets` and deprecated `assets_required` fields - asset_type_map = {} if format_obj: from adcp import get_format_assets, get_required_assets - # Build asset type map from all format assets for asset in get_format_assets(format_obj): asset_id = getattr(asset, "asset_id", None) asset_type = getattr(asset, "asset_type", None) - if asset_id and asset_type: - # Handle enum or string asset_type - if hasattr(asset_type, "value"): - asset_type_map[asset_id] = asset_type.value - else: - asset_type_map[asset_id] = str(asset_type) + asset_type_map[asset_id] = asset_type.value if hasattr(asset_type, "value") else str(asset_type) + continue + + asset_group_id = getattr(asset, "asset_group_id", None) + if asset_group_id: + inner_assets = getattr(asset, "assets", []) + if inner_assets: + inner_type = getattr(inner_assets[0], "asset_type", None) + if inner_type: + asset_type_map[asset_group_id] = ( + inner_type.value if hasattr(inner_type, "value") else str(inner_type) + ) + group_counts[asset_group_id] = ( + getattr(asset, "min_count", None), + getattr(asset, "max_count", None), + ) # Check required assets are present in manifest for required_asset in get_required_assets(format_obj): - asset_id = getattr(required_asset, "asset_id", None) + asset_id = getattr(required_asset, "asset_id", None) or getattr(required_asset, "asset_group_id", None) if asset_id and asset_id not in assets: errors.append(f"Required asset missing: {asset_id}") # Validate each asset for asset_id, asset_data in assets.items(): - # Get expected asset type from format spec expected_type = asset_type_map.get(asset_id) - if not expected_type: - # No format provided or asset not in format spec - # Try to infer type from asset data (for backward compatibility during transition) - if "url" in asset_data and "width" in asset_data and "height" in asset_data: - expected_type = "image" - elif "url" in asset_data and "duration_seconds" in asset_data: - expected_type = "video" # or audio, hard to distinguish - elif "content" in asset_data: - expected_type = "text" # could be html/js/css too - elif "url" in asset_data: - expected_type = "url" - else: - errors.append( - f"Asset '{asset_id}': Cannot determine asset type (format not provided or asset_id not in format spec)" - ) - continue - - try: - validate_asset(asset_data, expected_type, check_remote_mime=check_remote_mime) - except AssetValidationError as e: - errors.append(f"Asset '{asset_id}': {e}") + if isinstance(asset_data, list): + # Repeatable group: validate count constraints and each item + min_c, max_c = group_counts.get(asset_id, (None, None)) + if min_c is not None and len(asset_data) < min_c: + errors.append(f"Asset '{asset_id}': requires at least {min_c} items, got {len(asset_data)}") + if max_c is not None and len(asset_data) > max_c: + errors.append(f"Asset '{asset_id}': allows at most {max_c} items, got {len(asset_data)}") + + for i, item in enumerate(asset_data): + if not isinstance(item, dict) or len(item) != 1: + errors.append( + f"Asset '{asset_id}[{i}]': each item must be a single-key dict like {{\"text\": {{...}}}}" + ) + continue + item_type_key, item_data = next(iter(item.items())) + validate_type = expected_type or item_type_key + try: + validate_asset(item_data, validate_type, check_remote_mime=check_remote_mime) + except AssetValidationError as e: + errors.append(f"Asset '{asset_id}[{i}]': {e}") + + else: + # Individual asset + if not expected_type: + # Try to infer type from asset data + if "url" in asset_data and "width" in asset_data and "height" in asset_data: + expected_type = "image" + elif "url" in asset_data and "duration_seconds" in asset_data: + expected_type = "video" + elif "content" in asset_data: + expected_type = "text" + elif "url" in asset_data: + expected_type = "url" + else: + errors.append( + f"Asset '{asset_id}': Cannot determine asset type (format not provided or asset_id not in format spec)" + ) + continue + + try: + validate_asset(asset_data, expected_type, check_remote_mime=check_remote_mime) + except AssetValidationError as e: + errors.append(f"Asset '{asset_id}': {e}") return errors diff --git a/tests/integration/test_catalog_formats.py b/tests/integration/test_catalog_formats.py new file mode 100644 index 0000000..d9fb36d --- /dev/null +++ b/tests/integration/test_catalog_formats.py @@ -0,0 +1,103 @@ +"""Integration tests for catalog-aware format definitions. + +Generative formats declare catalog_requirements instead of a promoted_offerings +asset. This verifies the format definitions are correctly structured per ADCP 3.5.0. +""" + +from adcp import CatalogType, FormatId, get_format_assets +from pydantic import AnyUrl + +from creative_agent.data.standard_formats import AGENT_URL, STANDARD_FORMATS, get_format_by_id + +# All generative format IDs (template + concrete) +GENERATIVE_TEMPLATE_ID = "display_generative" +GENERATIVE_CONCRETE_IDS = [ + "display_300x250_generative", + "display_728x90_generative", + "display_160x600_generative", + "display_320x50_generative", + "display_336x280_generative", + "display_300x600_generative", + "display_970x250_generative", +] +ALL_GENERATIVE_IDS = [GENERATIVE_TEMPLATE_ID, *GENERATIVE_CONCRETE_IDS] + + +class TestGenerativeFormatCatalogRequirements: + """Generative formats must declare catalog_requirements.""" + + def test_generative_formats_have_catalog_requirements(self): + """Each generative format must have catalog_requirements.""" + for fmt_id_str in ALL_GENERATIVE_IDS: + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str) + fmt = get_format_by_id(fmt_id) + assert fmt is not None, f"{fmt_id_str} not found" + + reqs = getattr(fmt, "catalog_requirements", None) + assert reqs is not None, f"{fmt_id_str}: must have catalog_requirements" + assert len(reqs) > 0, f"{fmt_id_str}: catalog_requirements must not be empty" + + def test_catalog_requirements_type_is_offering(self): + """Generative formats require an offering catalog.""" + for fmt_id_str in ALL_GENERATIVE_IDS: + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str) + fmt = get_format_by_id(fmt_id) + + reqs = fmt.catalog_requirements + assert reqs[0].catalog_type == CatalogType.offering, f"{fmt_id_str}: catalog_type must be 'offering'" + + def test_catalog_requirements_required_fields(self): + """Offering catalog should require at least the 'name' field.""" + for fmt_id_str in ALL_GENERATIVE_IDS: + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str) + fmt = get_format_by_id(fmt_id) + + reqs = fmt.catalog_requirements + required_fields = reqs[0].required_fields or [] + assert "name" in required_fields, f"{fmt_id_str}: catalog should require 'name' field" + + +class TestGenerativeFormatAssets: + """Generative formats must still have generation_prompt and impression_tracker.""" + + def test_generative_formats_have_generation_prompt(self): + """Each generative format must have a generation_prompt asset.""" + for fmt_id_str in ALL_GENERATIVE_IDS: + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str) + fmt = get_format_by_id(fmt_id) + + asset_ids = {getattr(a, "asset_id", None) for a in get_format_assets(fmt)} + assert "generation_prompt" in asset_ids, f"{fmt_id_str}: must have generation_prompt asset" + + def test_generative_formats_have_impression_tracker(self): + """Each generative format must have an impression_tracker asset.""" + for fmt_id_str in ALL_GENERATIVE_IDS: + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str) + fmt = get_format_by_id(fmt_id) + + asset_ids = {getattr(a, "asset_id", None) for a in get_format_assets(fmt)} + assert "impression_tracker" in asset_ids, f"{fmt_id_str}: must have impression_tracker asset" + + def test_generative_formats_do_not_have_promoted_offerings(self): + """No format should have a promoted_offerings asset.""" + for fmt in STANDARD_FORMATS: + asset_ids = {getattr(a, "asset_id", None) for a in get_format_assets(fmt)} + assert "promoted_offerings" not in asset_ids, f"{fmt.format_id.id}: must NOT have promoted_offerings asset" + + +class TestCatalogRequirementsSerialization: + """catalog_requirements must serialize correctly for ADCP wire format.""" + + def test_catalog_requirements_roundtrip(self): + """catalog_requirements should serialize and validate.""" + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=GENERATIVE_TEMPLATE_ID) + fmt = get_format_by_id(fmt_id) + + fmt_dict = fmt.model_dump(mode="json", exclude_none=True) + + assert "catalog_requirements" in fmt_dict + reqs = fmt_dict["catalog_requirements"] + assert isinstance(reqs, list) + assert len(reqs) == 1 + assert reqs[0]["catalog_type"] == "offering" + assert "name" in reqs[0].get("required_fields", []) diff --git a/tests/integration/test_preview_creative.py.bak b/tests/integration/test_preview_creative.py.bak deleted file mode 100644 index 9e3b1c2..0000000 --- a/tests/integration/test_preview_creative.py.bak +++ /dev/null @@ -1,356 +0,0 @@ -"""Integration tests for preview_creative tool. - -All tests use generated Pydantic schemas to ensure 100% ADCP spec compliance. -""" - -from datetime import UTC, datetime - -import pytest -from pytest_mock import MockerFixture - -from creative_agent import server -from creative_agent.data.standard_formats import AGENT_URL -from adcp.types.generated import FormatId -from creative_agent.schemas import CreativeManifest - -# Get the actual function from the FastMCP wrapper -preview_creative = server.preview_creative.fn - - -@pytest.fixture -def mock_s3_upload(mocker: MockerFixture): - """Mock S3 upload to avoid network calls.""" - mock_upload = mocker.patch("creative_agent.storage.upload_preview_html") - mock_upload.return_value = "https://adcp-previews.fly.storage.tigris.dev/previews/test-id/desktop.html" - return mock_upload - - -class TestPreviewCreativeIntegration: - """Integration tests for the preview_creative tool using spec-compliant schemas.""" - - def test_preview_creative_with_spec_compliant_manifest(self, mock_s3_upload): - """Test preview_creative tool with fully spec-compliant manifest.""" - # Create spec-compliant Pydantic manifest - manifest = CreativeManifest( - creative_id="test-1", - name="Test Creative", - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - "url": "https://example.com/banner.png", - "width": 300, - "height": 250, - "format": "png", - }, - "click_url": { - "url": "https://example.com/landing", - }, - }, - ) - - # Convert to dict as server.py does - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Verify response structure - assert "previews" in structured - assert isinstance(structured["previews"], list) - assert len(structured["previews"]) == 3 # desktop, mobile, tablet - - # Verify each preview variant per ADCP spec - for preview in structured["previews"]: - assert "preview_id" in preview, "preview_id is required per spec" - assert "renders" in preview, "renders is required per spec" - assert len(preview["renders"]) > 0, "must have at least one render" - assert "input" in preview, "input is required per spec" - - # Verify S3 upload was called - assert mock_s3_upload.call_count == 3 - - def test_preview_creative_with_custom_inputs(self, mock_s3_upload): - """Test preview_creative with custom input variants.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - inputs=[ - {"name": "US Desktop", "macros": {"COUNTRY": "US", "DEVICE": "desktop"}}, - {"name": "UK Mobile", "macros": {"COUNTRY": "UK", "DEVICE": "mobile"}}, - ], - ) - - structured = result.structured_content - - assert len(structured["previews"]) == 2 - assert structured["previews"][0]["input"]["name"] == "US Desktop" - assert structured["previews"][1]["input"]["name"] == "UK Mobile" - - def test_preview_creative_validates_format_id_mismatch(self, mock_s3_upload): - """Test that preview_creative rejects manifest with mismatched format_id.""" - # Create manifest with DIFFERENT format_id than request - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_728x90_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=728, - height=90, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", # Different! - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "error" in structured - assert "does not match" in structured["error"] - # Verify error message contains the actual IDs for clarity - assert "display_728x90_image" in structured["error"] - assert "display_300x250_image" in structured["error"] - - def test_preview_creative_accepts_format_id_as_dict(self, mock_s3_upload): - """Test that preview_creative accepts format_id as FormatId object (dict).""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - # Pass format_id as dict (FormatId object) - result = preview_creative( - format_id={"agent_url": str(AGENT_URL), "id": "display_300x250_image"}, - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "previews" in structured - assert len(structured["previews"]) == 3 - - def test_preview_creative_validates_malicious_urls(self, mock_s3_upload): - """Test that preview_creative validates and sanitizes malicious URLs.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ).model_dump(mode="json") - - # Inject malicious URL after validation - manifest["assets"]["banner_image"]["url"] = "javascript:alert('xss')" - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest, - ) - - structured = result.structured_content - assert "error" in structured - assert "validation" in structured["error"].lower() - - def test_preview_creative_returns_interactive_url(self, mock_s3_upload): - """Test that preview response includes interactive_url.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "interactive_url" in structured - assert "preview/" in structured["interactive_url"] - - def test_preview_creative_returns_expiration(self, mock_s3_upload): - """Test that preview response includes expires_at timestamp.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "expires_at" in structured - # Should be ISO 8601 format - assert "T" in structured["expires_at"] - assert "Z" in structured["expires_at"] or "+" in structured["expires_at"] - - def test_preview_creative_rejects_unknown_format(self, mock_s3_upload): - """Test that preview_creative rejects unknown format_id.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="unknown_format_999", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "error" in structured - assert "not found" in structured["error"].lower() - - def test_preview_creative_returns_spec_compliant_response(self, mock_s3_upload): - """Test that response matches ADCP PreviewCreativeResponse spec exactly.""" - from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_response_json import ( - PreviewCreativeResponse, - ) - - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": UrlAsset(url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Validate against actual ADCP spec - response = PreviewCreativeResponse.model_validate(structured) - - # For single mode response, access via .root - assert hasattr(response.root, "previews") - assert response.root.previews is not None - assert response.root.expires_at is not None - assert len(response.root.previews) == 3 # desktop, mobile, tablet - - def test_preview_expiration_is_valid_iso8601_timestamp(self, mock_s3_upload): - """Test that expires_at is a valid ISO 8601 timestamp in the future.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - format="png", - ), - "click_url": UrlAsset( - url="https://example.com/landing", - ), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Verify expires_at exists and is a string - assert "expires_at" in structured - expires_at_str = structured["expires_at"] - assert isinstance(expires_at_str, str) - - # Verify it's valid ISO 8601 and in the future - expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) - now = datetime.now(UTC) - assert expires_at > now, "expires_at must be in the future" - - # Verify reasonable expiration duration (between 1 min and 48 hours) - time_until_expiry = (expires_at - now).total_seconds() - assert 60 <= time_until_expiry <= 48 * 3600, "expiration should be reasonable duration" - - def test_preview_creative_fails_with_missing_required_asset(self, mock_s3_upload): - """Test that preview_creative returns clear error when required asset is missing.""" - # Create manifest missing required click_url - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": ImageAsset( - url="https://example.com/banner.png", - width=300, - height=250, - ), - # Missing required click_url! - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Must return error, not crash - assert "error" in structured, "Should return error for missing required asset" - assert "validation" in structured["error"].lower(), "Error should mention validation" - - # Should have specific validation errors - assert "validation_errors" in structured, "Should include validation_errors array" - errors = structured["validation_errors"] - assert len(errors) > 0, "Should have at least one validation error" - - # Should mention the missing asset - error_messages = [str(err) for err in errors] - assert any("click_url" in str(msg).lower() for msg in error_messages), "Should mention missing click_url" diff --git a/tests/integration/test_preview_creative.py.bak2 b/tests/integration/test_preview_creative.py.bak2 deleted file mode 100644 index a10ad5d..0000000 --- a/tests/integration/test_preview_creative.py.bak2 +++ /dev/null @@ -1,356 +0,0 @@ -"""Integration tests for preview_creative tool. - -All tests use generated Pydantic schemas to ensure 100% ADCP spec compliance. -""" - -from datetime import UTC, datetime - -import pytest -from pytest_mock import MockerFixture - -from creative_agent import server -from creative_agent.data.standard_formats import AGENT_URL -from adcp.types.generated import FormatId -from creative_agent.schemas import CreativeManifest - -# Get the actual function from the FastMCP wrapper -preview_creative = server.preview_creative.fn - - -@pytest.fixture -def mock_s3_upload(mocker: MockerFixture): - """Mock S3 upload to avoid network calls.""" - mock_upload = mocker.patch("creative_agent.storage.upload_preview_html") - mock_upload.return_value = "https://adcp-previews.fly.storage.tigris.dev/previews/test-id/desktop.html" - return mock_upload - - -class TestPreviewCreativeIntegration: - """Integration tests for the preview_creative tool using spec-compliant schemas.""" - - def test_preview_creative_with_spec_compliant_manifest(self, mock_s3_upload): - """Test preview_creative tool with fully spec-compliant manifest.""" - # Create spec-compliant Pydantic manifest - manifest = CreativeManifest( - creative_id="test-1", - name="Test Creative", - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - "url": "https://example.com/banner.png", - "width": 300, - "height": 250, - "format": "png", - }, - "click_url": { - "url": "https://example.com/landing", - }, - }, - ) - - # Convert to dict as server.py does - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Verify response structure - assert "previews" in structured - assert isinstance(structured["previews"], list) - assert len(structured["previews"]) == 3 # desktop, mobile, tablet - - # Verify each preview variant per ADCP spec - for preview in structured["previews"]: - assert "preview_id" in preview, "preview_id is required per spec" - assert "renders" in preview, "renders is required per spec" - assert len(preview["renders"]) > 0, "must have at least one render" - assert "input" in preview, "input is required per spec" - - # Verify S3 upload was called - assert mock_s3_upload.call_count == 3 - - def test_preview_creative_with_custom_inputs(self, mock_s3_upload): - """Test preview_creative with custom input variants.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - inputs=[ - {"name": "US Desktop", "macros": {"COUNTRY": "US", "DEVICE": "desktop"}}, - {"name": "UK Mobile", "macros": {"COUNTRY": "UK", "DEVICE": "mobile"}}, - ], - ) - - structured = result.structured_content - - assert len(structured["previews"]) == 2 - assert structured["previews"][0]["input"]["name"] == "US Desktop" - assert structured["previews"][1]["input"]["name"] == "UK Mobile" - - def test_preview_creative_validates_format_id_mismatch(self, mock_s3_upload): - """Test that preview_creative rejects manifest with mismatched format_id.""" - # Create manifest with DIFFERENT format_id than request - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_728x90_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=728, - height=90, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", # Different! - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "error" in structured - assert "does not match" in structured["error"] - # Verify error message contains the actual IDs for clarity - assert "display_728x90_image" in structured["error"] - assert "display_300x250_image" in structured["error"] - - def test_preview_creative_accepts_format_id_as_dict(self, mock_s3_upload): - """Test that preview_creative accepts format_id as FormatId object (dict).""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - # Pass format_id as dict (FormatId object) - result = preview_creative( - format_id={"agent_url": str(AGENT_URL), "id": "display_300x250_image"}, - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "previews" in structured - assert len(structured["previews"]) == 3 - - def test_preview_creative_validates_malicious_urls(self, mock_s3_upload): - """Test that preview_creative validates and sanitizes malicious URLs.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ).model_dump(mode="json") - - # Inject malicious URL after validation - manifest["assets"]["banner_image"]["url"] = "javascript:alert('xss')" - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest, - ) - - structured = result.structured_content - assert "error" in structured - assert "validation" in structured["error"].lower() - - def test_preview_creative_returns_interactive_url(self, mock_s3_upload): - """Test that preview response includes interactive_url.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "interactive_url" in structured - assert "preview/" in structured["interactive_url"] - - def test_preview_creative_returns_expiration(self, mock_s3_upload): - """Test that preview response includes expires_at timestamp.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "expires_at" in structured - # Should be ISO 8601 format - assert "T" in structured["expires_at"] - assert "Z" in structured["expires_at"] or "+" in structured["expires_at"] - - def test_preview_creative_rejects_unknown_format(self, mock_s3_upload): - """Test that preview_creative rejects unknown format_id.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="unknown_format_999", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - assert "error" in structured - assert "not found" in structured["error"].lower() - - def test_preview_creative_returns_spec_compliant_response(self, mock_s3_upload): - """Test that response matches ADCP PreviewCreativeResponse spec exactly.""" - from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_response_json import ( - PreviewCreativeResponse, - ) - - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - "click_url": {url="https://example.com/landing"), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Validate against actual ADCP spec - response = PreviewCreativeResponse.model_validate(structured) - - # For single mode response, access via .root - assert hasattr(response.root, "previews") - assert response.root.previews is not None - assert response.root.expires_at is not None - assert len(response.root.previews) == 3 # desktop, mobile, tablet - - def test_preview_expiration_is_valid_iso8601_timestamp(self, mock_s3_upload): - """Test that expires_at is a valid ISO 8601 timestamp in the future.""" - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - format="png", - ), - "click_url": { - url="https://example.com/landing", - ), - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Verify expires_at exists and is a string - assert "expires_at" in structured - expires_at_str = structured["expires_at"] - assert isinstance(expires_at_str, str) - - # Verify it's valid ISO 8601 and in the future - expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) - now = datetime.now(UTC) - assert expires_at > now, "expires_at must be in the future" - - # Verify reasonable expiration duration (between 1 min and 48 hours) - time_until_expiry = (expires_at - now).total_seconds() - assert 60 <= time_until_expiry <= 48 * 3600, "expiration should be reasonable duration" - - def test_preview_creative_fails_with_missing_required_asset(self, mock_s3_upload): - """Test that preview_creative returns clear error when required asset is missing.""" - # Create manifest missing required click_url - manifest = CreativeManifest( - format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"), - assets={ - "banner_image": { - url="https://example.com/banner.png", - width=300, - height=250, - ), - # Missing required click_url! - }, - ) - - result = preview_creative( - format_id="display_300x250_image", - creative_manifest=manifest.model_dump(mode="json"), - ) - - structured = result.structured_content - - # Must return error, not crash - assert "error" in structured, "Should return error for missing required asset" - assert "validation" in structured["error"].lower(), "Error should mention validation" - - # Should have specific validation errors - assert "validation_errors" in structured, "Should include validation_errors array" - errors = structured["validation_errors"] - assert len(errors) > 0, "Should have at least one validation error" - - # Should mention the missing asset - error_messages = [str(err) for err in errors] - assert any("click_url" in str(msg).lower() for msg in error_messages), "Should mention missing click_url" diff --git a/tests/integration/test_template_formats.py b/tests/integration/test_template_formats.py index bfc6a35..cfe1a51 100644 --- a/tests/integration/test_template_formats.py +++ b/tests/integration/test_template_formats.py @@ -258,10 +258,10 @@ def test_concrete_formats_have_explicit_requirements(self): # Should have explicit width/height requirements requirements = getattr(image_asset, "requirements", None) assert requirements is not None - assert "width" in requirements, "Concrete format should have explicit width" - assert "height" in requirements, "Concrete format should have explicit height" - assert requirements["width"] == 300 - assert requirements["height"] == 250 + assert hasattr(requirements, "width"), "Concrete format should have explicit width" + assert hasattr(requirements, "height"), "Concrete format should have explicit height" + assert requirements.width == 300 + assert requirements.height == 250 class TestTemplateFormatSerialization: @@ -325,8 +325,9 @@ def test_template_with_dimensions_has_no_output_format_ids(self): if accepts_params and FormatIdParameter.dimensions in accepts_params: output_formats = getattr(fmt, "output_format_ids", None) - # Generative template might have output formats, but dimension templates shouldn't - if fmt.format_id.id not in ["display_generative"]: + # Generative templates (e.g. display_generative) have output_format_ids by design; + # pure dimension templates should not + if fmt.format_id.id != "display_generative": assert output_formats is None or len(output_formats) == 0, ( f"{fmt.format_id.id}: dimension template should not have output_format_ids" ) diff --git a/tests/integration/test_tool_response_formats.py b/tests/integration/test_tool_response_formats.py index 4b2f7db..42bf58b 100644 --- a/tests/integration/test_tool_response_formats.py +++ b/tests/integration/test_tool_response_formats.py @@ -122,7 +122,7 @@ def test_no_extra_wrapper_fields(self): ) def test_assets_have_asset_id(self): - """Per ADCP PR #135, all assets must have asset_id field.""" + """Per ADCP PR #135, all assets must have asset_id or asset_group_id field.""" result = list_creative_formats() response = ListCreativeFormatsResponse.model_validate(result.structured_content) @@ -131,10 +131,11 @@ def test_assets_have_asset_id(self): for fmt in formats_with_assets: for asset in get_required_assets(fmt): - # Access asset_id - will raise AttributeError if missing asset_dict = asset.model_dump() if hasattr(asset, "model_dump") else dict(asset) - assert "asset_id" in asset_dict, f"Format {fmt.format_id.id} has asset without asset_id: {asset_dict}" - assert asset_dict["asset_id"], f"Format {fmt.format_id.id} has empty asset_id: {asset_dict}" + has_id = "asset_id" in asset_dict or "asset_group_id" in asset_dict + assert has_id, f"Format {fmt.format_id.id} has asset without asset_id or asset_group_id: {asset_dict}" + identifier = asset_dict.get("asset_id") or asset_dict.get("asset_group_id") + assert identifier, f"Format {fmt.format_id.id} has empty asset identifier: {asset_dict}" def test_backward_compat_assets_required_field(self): """For 2.5.x client compatibility, formats must include assets_required field.""" diff --git a/tests/validation/test_asset_validation.py b/tests/validation/test_asset_validation.py index 33cdc0b..1c48e0b 100644 --- a/tests/validation/test_asset_validation.py +++ b/tests/validation/test_asset_validation.py @@ -179,10 +179,26 @@ def test_size_limit_fails(self): """Data URI exceeding size limit should fail.""" large_data = "x" * (11 * 1024 * 1024) # 11MB uri = f"data:image/png;base64,{large_data}" - with pytest.raises(AssetValidationError, match="exceeds 10MB"): + with pytest.raises(AssetValidationError, match="exceeds size limit"): validate_data_uri(uri) +class TestDataURIInValidateURL: + """Verify data URIs are rejected by validate_url (only allowed via validate_image_url).""" + + def test_data_uri_rejected_by_validate_url(self): + """data: URIs should not pass through the general validate_url function.""" + uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + with pytest.raises(AssetValidationError): + validate_url(uri) + + def test_data_uri_svg_rejected_by_validate_url(self): + """data:image/svg+xml URIs should not pass through validate_url.""" + uri = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==" + with pytest.raises(AssetValidationError): + validate_url(uri) + + class TestImageURLValidation: """Test image URL validation.""" @@ -444,39 +460,3 @@ def test_invalid_manifest_type_fails(self): errors = validate_manifest_assets("not a dict") assert len(errors) == 1 assert "must be a dictionary" in errors[0] - - def test_valid_promoted_offerings_with_url(self): - """Valid promoted offerings with brand manifest URL should pass.""" - asset = { - "brand_manifest": "https://brand.example.com/manifest.json", - } - validate_asset(asset, "promoted_offerings") - - def test_valid_promoted_offerings_with_inline_manifest(self): - """Valid promoted offerings with inline brand manifest should pass.""" - asset = { - "brand_manifest": { - "url": "https://brand.example.com", - "name": "ACME Corp", - }, - } - validate_asset(asset, "promoted_offerings") - - def test_valid_promoted_offerings_with_name_only(self): - """Valid promoted offerings with name-only brand manifest should pass.""" - asset = { - "brand_manifest": { - "name": "ACME Corp", - }, - } - validate_asset(asset, "promoted_offerings") - - def test_promoted_offerings_inline_manifest_without_url_or_name_fails(self): - """Promoted offerings with inline manifest missing url and name should fail.""" - asset = { - "brand_manifest": { - "colors": {"primary": "#FF0000"}, - }, - } - with pytest.raises(AssetValidationError, match="must have either url or name"): - validate_asset(asset, "promoted_offerings") diff --git a/tests/validation/test_catalog_validation.py b/tests/validation/test_catalog_validation.py new file mode 100644 index 0000000..126e6d5 --- /dev/null +++ b/tests/validation/test_catalog_validation.py @@ -0,0 +1,387 @@ +"""Validation tests for catalog entries in creative manifests. + +Tests validate_catalog() and the catalog path in validate_manifest_assets() +per ADCP 3.5.0 Catalog model. +""" + +from creative_agent.validation import validate_catalog, validate_manifest_assets + + +class TestValidateCatalog: + """Unit tests for validate_catalog().""" + + def test_valid_catalog_passes(self): + catalog = {"type": "offering", "items": [{"name": "Widget"}]} + errors = validate_catalog(catalog) + assert errors == [] + + def test_missing_type_fails(self): + catalog = {"items": [{"name": "Widget"}]} + errors = validate_catalog(catalog) + assert any("'type' field" in e for e in errors) + + def test_invalid_type_fails(self): + catalog = {"type": "bogus"} + errors = validate_catalog(catalog) + assert any("Invalid catalog type" in e for e in errors) + + def test_valid_catalog_with_url(self): + catalog = {"type": "product", "url": "https://feeds.example.com/products.json"} + errors = validate_catalog(catalog) + assert errors == [] + + def test_invalid_catalog_url_fails(self): + catalog = {"type": "product", "url": "not-a-url"} + errors = validate_catalog(catalog) + assert any("url" in e.lower() for e in errors) + + def test_items_must_be_list(self): + catalog = {"type": "offering", "items": "not a list"} + errors = validate_catalog(catalog) + assert any("items must be a list" in e for e in errors) + + def test_items_must_be_dicts(self): + catalog = {"type": "offering", "items": ["not a dict"]} + requirements = {"required_fields": ["name"]} + errors = validate_catalog(catalog, requirements) + assert any("must be a dictionary" in e for e in errors) + + +class TestCatalogRequiredFields: + """Tests for required_fields enforcement on catalog items.""" + + def test_required_fields_present(self): + catalog = {"type": "offering", "items": [{"name": "Widget", "price": "9.99"}]} + requirements = {"required_fields": ["name", "price"]} + errors = validate_catalog(catalog, requirements) + assert errors == [] + + def test_missing_required_field_fails(self): + catalog = {"type": "offering", "items": [{"price": "9.99"}]} + requirements = {"required_fields": ["name"]} + errors = validate_catalog(catalog, requirements) + assert any("missing required field 'name'" in e for e in errors) + + def test_multiple_items_validated(self): + catalog = { + "type": "offering", + "items": [ + {"name": "Good"}, + {"description": "Missing name"}, + ], + } + requirements = {"required_fields": ["name"]} + errors = validate_catalog(catalog, requirements) + # Only second item should fail + assert len(errors) == 1 + assert "item[1]" in errors[0] + + +class TestCatalogMinItems: + """Tests for min_items constraint.""" + + def test_min_items_satisfied(self): + catalog = {"type": "offering", "items": [{"name": "A"}, {"name": "B"}]} + requirements = {"min_items": 2} + errors = validate_catalog(catalog, requirements) + assert errors == [] + + def test_min_items_violated(self): + catalog = {"type": "offering", "items": [{"name": "A"}]} + requirements = {"min_items": 3} + errors = validate_catalog(catalog, requirements) + assert any("at least 3 items" in e for e in errors) + + +class TestCatalogOfferingAssetConstraints: + """Tests for offering_asset_constraints validation on catalog items.""" + + def test_valid_offering_assets(self): + catalog = { + "type": "offering", + "items": [ + { + "name": "Widget", + "assets": { + "product_images": [ + {"url": "https://cdn.example.com/img1.jpg"}, + ], + }, + }, + ], + } + requirements = { + "offering_asset_constraints": [ + { + "asset_group_id": "product_images", + "asset_type": "image", + "required": True, + "min_count": 1, + "max_count": 5, + }, + ], + } + errors = validate_catalog(catalog, requirements) + assert errors == [] + + def test_missing_required_asset_group_fails(self): + catalog = { + "type": "offering", + "items": [{"name": "Widget", "assets": {}}], + } + requirements = { + "offering_asset_constraints": [ + { + "asset_group_id": "product_images", + "asset_type": "image", + "required": True, + }, + ], + } + errors = validate_catalog(catalog, requirements) + assert any("missing required asset group 'product_images'" in e for e in errors) + + def test_optional_asset_group_not_required(self): + catalog = { + "type": "offering", + "items": [{"name": "Widget", "assets": {}}], + } + requirements = { + "offering_asset_constraints": [ + { + "asset_group_id": "product_images", + "asset_type": "image", + "required": False, + }, + ], + } + errors = validate_catalog(catalog, requirements) + assert errors == [] + + def test_max_count_exceeded_fails(self): + catalog = { + "type": "offering", + "items": [ + { + "name": "Widget", + "assets": { + "product_images": [ + {"url": "https://cdn.example.com/1.jpg"}, + {"url": "https://cdn.example.com/2.jpg"}, + {"url": "https://cdn.example.com/3.jpg"}, + ], + }, + }, + ], + } + requirements = { + "offering_asset_constraints": [ + { + "asset_group_id": "product_images", + "asset_type": "image", + "required": True, + "max_count": 2, + }, + ], + } + errors = validate_catalog(catalog, requirements) + assert any("at most 2 items" in e for e in errors) + + def test_invalid_asset_content_fails(self): + catalog = { + "type": "offering", + "items": [ + { + "name": "Widget", + "assets": { + "descriptions": [ + {"content": ""}, + ], + }, + }, + ], + } + requirements = { + "offering_asset_constraints": [ + { + "asset_group_id": "descriptions", + "asset_type": "text", + "required": True, + }, + ], + } + errors = validate_catalog(catalog, requirements) + assert any("cannot be empty" in e.lower() for e in errors) + + +class TestCatalogEnumFields: + """Tests for enum field validation on catalogs.""" + + def test_valid_feed_format(self): + catalog = {"type": "product", "feed_format": "google_merchant_center"} + errors = validate_catalog(catalog) + assert errors == [] + + def test_invalid_feed_format(self): + catalog = {"type": "product", "feed_format": "amazon_feed"} + errors = validate_catalog(catalog) + assert any("Invalid feed_format" in e for e in errors) + + def test_valid_content_id_type(self): + catalog = {"type": "product", "content_id_type": "sku"} + errors = validate_catalog(catalog) + assert errors == [] + + def test_invalid_content_id_type(self): + catalog = {"type": "product", "content_id_type": "barcode"} + errors = validate_catalog(catalog) + assert any("Invalid content_id_type" in e for e in errors) + + def test_valid_update_frequency(self): + catalog = {"type": "product", "update_frequency": "daily"} + errors = validate_catalog(catalog) + assert errors == [] + + def test_invalid_update_frequency(self): + catalog = {"type": "product", "update_frequency": "every_5_minutes"} + errors = validate_catalog(catalog) + assert any("Invalid update_frequency" in e for e in errors) + + def test_valid_conversion_events(self): + catalog = {"type": "offering", "conversion_events": ["purchase", "add_to_cart"]} + errors = validate_catalog(catalog) + assert errors == [] + + def test_invalid_conversion_event(self): + catalog = {"type": "offering", "conversion_events": ["purchase", "bogus_event"]} + errors = validate_catalog(catalog) + assert any("Invalid conversion_event[1]" in e for e in errors) + + def test_conversion_events_must_be_list(self): + catalog = {"type": "offering", "conversion_events": "purchase"} + errors = validate_catalog(catalog) + assert any("conversion_events must be a list" in e for e in errors) + + def test_all_enum_fields_valid_together(self): + catalog = { + "type": "product", + "feed_format": "shopify", + "content_id_type": "sku", + "update_frequency": "hourly", + "conversion_events": ["purchase", "view_content"], + } + errors = validate_catalog(catalog) + assert errors == [] + + +class TestCatalogFeedFormatCompatibility: + """Tests for feed_format compatibility between catalog and requirements.""" + + def _generative_format(self): + from adcp import FormatId + from pydantic import AnyUrl + + from creative_agent.data.standard_formats import AGENT_URL, get_format_by_id + + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id="display_generative") + return get_format_by_id(fmt_id) + + def test_feed_format_compatible(self): + """Catalog feed_format matches one of the requirement's feed_formats.""" + from adcp import CatalogRequirements, CatalogType, FeedFormat, Format, FormatId + from pydantic import AnyUrl + + fmt = Format( + format_id=FormatId(agent_url=AnyUrl("https://test.example.com"), id="test_fmt"), + name="Test Format", + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.product, + feed_formats=[FeedFormat.google_merchant_center, FeedFormat.shopify], + ), + ], + ) + manifest = { + "assets": {"banner": {"url": "https://cdn.example.com/img.jpg", "width": 300, "height": 250}}, + "catalogs": [{"type": "product", "feed_format": "google_merchant_center"}], + } + errors = validate_manifest_assets(manifest, format_obj=fmt) + assert not any("feed_format" in e for e in errors) + + def test_feed_format_incompatible(self): + """Catalog feed_format does not match any of the requirement's feed_formats.""" + from adcp import CatalogRequirements, CatalogType, FeedFormat, Format, FormatId + from pydantic import AnyUrl + + fmt = Format( + format_id=FormatId(agent_url=AnyUrl("https://test.example.com"), id="test_fmt"), + name="Test Format", + catalog_requirements=[ + CatalogRequirements( + catalog_type=CatalogType.product, + feed_formats=[FeedFormat.google_merchant_center], + ), + ], + ) + manifest = { + "assets": {"banner": {"url": "https://cdn.example.com/img.jpg", "width": 300, "height": 250}}, + "catalogs": [{"type": "product", "feed_format": "shopify"}], + } + errors = validate_manifest_assets(manifest, format_obj=fmt) + assert any("feed_format" in e and "shopify" in e for e in errors) + + +class TestManifestCatalogValidation: + """Tests for catalog validation within validate_manifest_assets().""" + + def _generative_format(self): + """Get a generative format with catalog_requirements.""" + from adcp import FormatId + from pydantic import AnyUrl + + from creative_agent.data.standard_formats import AGENT_URL, get_format_by_id + + fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id="display_generative") + return get_format_by_id(fmt_id) + + def test_manifest_with_catalog_passes(self): + fmt = self._generative_format() + manifest = { + "format_id": {"agent_url": "https://creative.example.com", "id": "display_generative"}, + "assets": { + "generation_prompt": {"content": "Create a banner ad"}, + "impression_tracker": {"url": "https://track.example.com/imp"}, + }, + "catalogs": [ + {"type": "offering", "items": [{"name": "Widget"}]}, + ], + } + errors = validate_manifest_assets(manifest, format_obj=fmt) + assert errors == [], f"Unexpected errors: {errors}" + + def test_manifest_missing_required_catalog_fails(self): + fmt = self._generative_format() + manifest = { + "format_id": {"agent_url": "https://creative.example.com", "id": "display_generative"}, + "assets": { + "generation_prompt": {"content": "Create a banner ad"}, + "impression_tracker": {"url": "https://track.example.com/imp"}, + }, + } + errors = validate_manifest_assets(manifest, format_obj=fmt) + assert any("Required catalog missing" in e for e in errors) + + def test_manifest_catalog_wrong_type_fails(self): + fmt = self._generative_format() + manifest = { + "format_id": {"agent_url": "https://creative.example.com", "id": "display_generative"}, + "assets": { + "generation_prompt": {"content": "Create a banner ad"}, + "impression_tracker": {"url": "https://track.example.com/imp"}, + }, + "catalogs": [ + {"type": "job", "items": [{"title": "Engineer"}]}, + ], + } + errors = validate_manifest_assets(manifest, format_obj=fmt) + assert any("Required catalog missing for type: offering" in e for e in errors) diff --git a/tests/validation/test_template_parameter_validation.py b/tests/validation/test_template_parameter_validation.py index 4c55626..7877c1f 100644 --- a/tests/validation/test_template_parameter_validation.py +++ b/tests/validation/test_template_parameter_validation.py @@ -163,11 +163,12 @@ def test_concrete_format_has_explicit_dimensions(self): fmt = get_format_by_id(format_id) image_asset = next(a for a in get_required_assets(fmt) if a.asset_id == "banner_image") - requirements = getattr(image_asset, "requirements", {}) + requirements = getattr(image_asset, "requirements", None) + assert requirements is not None # Concrete format has explicit values in requirements - assert requirements.get("width") == 300 - assert requirements.get("height") == 250 + assert requirements.width == 300 + assert requirements.height == 250 class TestParameterValidationEdgeCases: diff --git a/uv.lock b/uv.lock index a98f55a..1fe1f64 100644 --- a/uv.lock +++ b/uv.lock @@ -24,8 +24,8 @@ wheels = [ [[package]] name = "adcp" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } +version = "3.5.0" +source = { git = "https://github.com/adcontextprotocol/adcp-client-python?tag=v3.5.0#5576bf42f26f0b0748587be65930c4c4cbeb7c30" } dependencies = [ { name = "a2a-sdk" }, { name = "email-validator" }, @@ -34,10 +34,6 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/9b/283f9efa8d904d8edb111a922ad2c415ee50134f4f5b3a6db3049c8e9b66/adcp-3.2.0.tar.gz", hash = "sha256:2113bfda4f4dd1fe690ed698ed0ec5b30447be3a5328a4ad5ab5a02c308f6083", size = 245028, upload-time = "2026-02-04T02:06:07.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/b7/4f0eaad37a01aab12fef1a09900a7ca0ae947c2cc9f7964c208c689d054c/adcp-3.2.0-py3-none-any.whl", hash = "sha256:e3ed195ad9bce525e976d148d9d092ce705f7246ef54f1cc3e7aea30186dd483", size = 308217, upload-time = "2026-02-04T02:06:05.531Z" }, -] [[package]] name = "adcp-creative-agent" @@ -47,7 +43,6 @@ dependencies = [ { name = "adcp" }, { name = "bleach" }, { name = "boto3" }, - { name = "fastapi" }, { name = "fastmcp" }, { name = "google-genai" }, { name = "httpx" }, @@ -73,10 +68,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "adcp", specifier = ">=3.2.0" }, + { name = "adcp", git = "https://github.com/adcontextprotocol/adcp-client-python?tag=v3.5.0" }, { name = "bleach", specifier = ">=6.3.0" }, { name = "boto3", specifier = ">=1.35.0" }, - { name = "fastapi", specifier = ">=0.100.0" }, { name = "fastmcp", specifier = ">=2.11.0" }, { name = "google-genai", specifier = ">=1.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -549,20 +543,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] -[[package]] -name = "fastapi" -version = "0.118.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/1b/6cbc5bc6d7a07a506c2275d443e4517adb4e02ab42e0a6486568e1749896/fastapi-0.118.1.tar.gz", hash = "sha256:063f9d4ff5bcdfd1ef6e4e6b44ed5fb5f4bf370b39cdce1c9aed22413c371cfe", size = 311185, upload-time = "2025-10-08T09:07:24.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/29/9bf43b2fe09854a4c743eed5ef9b6a84bab39b05bfd46aea7ce6c7bf97f1/fastapi-0.118.1-py3-none-any.whl", hash = "sha256:be88c15c995464d14d2be1d7059860551aeffb9df889688bcea7050c9635badf", size = 97933, upload-time = "2025-10-08T09:07:22.003Z" }, -] - [[package]] name = "fastmcp" version = "2.12.4"