Coverage for fastblocks/adapters/images/cloudflare.py: 0%
151 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""Cloudflare Images adapter for FastBlocks."""
3import asyncio
4from contextlib import suppress
5from typing import Any
6from uuid import UUID, uuid4
8import httpx
9from acb.config import Settings
10from acb.depends import depends
11from pydantic import SecretStr
13from ._base import ImagesBase
16class CloudflareImagesSettings(Settings): # type: ignore[misc]
17 """Settings for Cloudflare Images adapter."""
19 # Required ACB 0.19.0+ metadata
20 MODULE_ID: UUID = UUID("01937d86-5e3b-7f4c-9e8e-a5b6c7d8e9f0") # Static UUID7
21 MODULE_STATUS: str = "stable"
23 # Cloudflare API configuration
24 account_id: str = ""
25 api_token: SecretStr = SecretStr("")
26 delivery_url: str = "" # https://imagedelivery.net/{account_hash}
28 # Image configuration
29 default_variant: str = "public"
30 require_signed_urls: bool = False
31 timeout: int = 30
33 # R2 storage configuration (optional)
34 r2_bucket: str | None = None
35 r2_public_url: str | None = None
38class CloudflareImagesAdapter(ImagesBase):
39 """Cloudflare Images adapter with R2 storage integration."""
41 # Required ACB 0.19.0+ metadata
42 MODULE_ID: UUID = UUID("01937d86-5e3b-7f4c-9e8e-a5b6c7d8e9f0") # Static UUID7
43 MODULE_STATUS: str = "stable"
45 def __init__(self) -> None:
46 """Initialize Cloudflare Images adapter."""
47 super().__init__()
48 self.settings: CloudflareImagesSettings | None = None
49 self._client: httpx.AsyncClient | None = None
51 # Register with ACB dependency system
52 with suppress(Exception):
53 depends.set(self)
55 async def _get_client(self) -> httpx.AsyncClient:
56 """Get or create HTTP client."""
57 if not self._client:
58 if not self.settings:
59 self.settings = CloudflareImagesSettings()
61 self._client = httpx.AsyncClient(
62 timeout=self.settings.timeout,
63 headers={
64 "Authorization": f"Bearer {self.settings.api_token.get_secret_value()}",
65 "Content-Type": "application/json",
66 },
67 )
68 return self._client
70 async def upload_image(self, file_data: bytes, filename: str) -> str:
71 """Upload image to Cloudflare Images."""
72 if not self.settings:
73 self.settings = CloudflareImagesSettings()
75 client = await self._get_client()
77 # Generate unique ID for the image
78 str(uuid4())
80 # Prepare upload data
82 # Upload to Cloudflare Images
83 url = f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/images/v1"
85 # Format files for httpx - each value is a tuple of (filename, file_content, content_type)
86 files_data: dict[str, tuple[str, bytes, str]] = {
87 "file": (filename, file_data, "image/*"),
88 }
90 response = await client.post(url, files=files_data)
91 response.raise_for_status()
93 result = response.json()
94 if not result.get("success"):
95 raise RuntimeError(
96 f"Upload failed: {result.get('errors', 'Unknown error')}"
97 )
99 # Return the image ID for future reference
100 uploaded_image_id: str = result["result"]["id"]
101 return uploaded_image_id
103 async def get_image_url(
104 self, image_id: str, transformations: dict[str, Any] | None = None
105 ) -> str:
106 """Generate Cloudflare Images URL with transformations."""
107 if not self.settings:
108 self.settings = CloudflareImagesSettings()
110 base_url = self._build_base_url(image_id)
112 if transformations:
113 transform_parts = self._build_transformation_parts(transformations)
114 if transform_parts:
115 return self._build_transformed_url(
116 base_url, transformations, transform_parts
117 )
119 return f"{base_url}/{self.settings.default_variant}"
121 def _build_base_url(self, image_id: str) -> str:
122 """Build base URL for Cloudflare image."""
123 if not self.settings:
124 raise RuntimeError("Cloudflare Images settings not configured")
126 if self.settings.delivery_url:
127 return f"{self.settings.delivery_url}/{image_id}"
128 return f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/images/v1/{image_id}"
130 def _build_transformation_parts(self, transformations: dict[str, Any]) -> list[str]:
131 """Build transformation query parameters."""
132 # Common transformations
133 common_parts = [
134 f"{key}={transformations[key]}"
135 for key in ("width", "height", "quality", "format", "fit")
136 if key in transformations
137 ]
139 # Advanced transformations
140 advanced_parts = [
141 f"{key}={transformations[key]}"
142 for key in ("blur", "brightness", "contrast", "gamma", "sharpen")
143 if key in transformations
144 ]
146 return common_parts + advanced_parts
148 def _build_transformed_url(
149 self, base_url: str, transformations: dict[str, Any], transform_parts: list[str]
150 ) -> str:
151 """Build final URL with transformations."""
152 if not self.settings:
153 raise RuntimeError("Cloudflare Images settings not configured")
155 variant = transformations.get("variant", self.settings.default_variant)
156 transform_string = ",".join(transform_parts)
157 return f"{base_url}/{variant}?{transform_string}"
159 def get_img_tag(self, image_id: str, alt: str, **attributes: Any) -> str:
160 """Generate img tag with Cloudflare Images patterns."""
161 # Generate base URL
162 transformations = attributes.pop("transformations", {})
164 # Use async context for URL generation (in real usage)
165 try:
166 loop = asyncio.get_event_loop()
167 url = loop.run_until_complete(self.get_image_url(image_id, transformations))
168 except RuntimeError:
169 # Create new event loop if none exists
170 loop = asyncio.new_event_loop()
171 asyncio.set_event_loop(loop)
172 url = loop.run_until_complete(self.get_image_url(image_id, transformations))
174 # Build attributes
175 img_attrs = {
176 "src": url,
177 "alt": alt,
178 "loading": "lazy"
179 if self.settings and self.settings.lazy_loading
180 else "eager",
181 } | attributes
183 # Generate tag
184 attr_string = " ".join(
185 f'{k}="{v}"' for k, v in img_attrs.items() if v is not None
186 )
187 return f"<img {attr_string}>"
189 async def delete_image(self, image_id: str) -> bool:
190 """Delete image from Cloudflare Images."""
191 if not self.settings:
192 self.settings = CloudflareImagesSettings()
194 client = await self._get_client()
196 url = f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/images/v1/{image_id}"
198 response = await client.delete(url)
199 response.raise_for_status()
201 result = response.json()
202 success: bool = result.get("success", False)
203 return success
205 async def list_images(self, page: int = 1, per_page: int = 50) -> dict[str, Any]:
206 """List images in Cloudflare Images."""
207 if not self.settings:
208 self.settings = CloudflareImagesSettings()
210 client = await self._get_client()
212 url = f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/images/v1"
213 params = {"page": page, "per_page": per_page}
215 response = await client.get(url, params=params)
216 response.raise_for_status()
218 result: dict[str, Any] = response.json()
219 return result
221 async def get_usage_stats(self) -> dict[str, Any]:
222 """Get Cloudflare Images usage statistics."""
223 if not self.settings:
224 self.settings = CloudflareImagesSettings()
226 client = await self._get_client()
228 url = f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/images/v1/stats"
230 response = await client.get(url)
231 response.raise_for_status()
233 stats: dict[str, Any] = response.json()
234 return stats
236 async def close(self) -> None:
237 """Close HTTP client."""
238 if self._client:
239 await self._client.aclose()
240 self._client = None
243# Template filter registration for FastBlocks
244def register_cloudflare_filters(env: Any) -> None:
245 """Register Cloudflare Images filters for Jinja2 templates."""
247 @env.filter("cf_image_url") # type: ignore[misc]
248 async def cf_image_url_filter(image_id: str, **transformations: Any) -> str:
249 """Template filter for Cloudflare Images URLs."""
250 images = depends.get("images")
251 if isinstance(images, CloudflareImagesAdapter):
252 return await images.get_image_url(image_id, transformations)
253 return f"#{image_id}" # Fallback
255 @env.filter("cf_img_tag") # type: ignore[misc]
256 def cf_img_tag_filter(image_id: str, alt: str = "", **attributes: Any) -> str:
257 """Template filter for complete Cloudflare img tags."""
258 images = depends.get("images")
259 if isinstance(images, CloudflareImagesAdapter):
260 return images.get_img_tag(image_id, alt, **attributes)
261 return f'<img src="#{image_id}" alt="{alt}">' # Fallback
263 @env.global_("cloudflare_responsive_img") # type: ignore[misc]
264 def cloudflare_responsive_img(
265 image_id: str,
266 alt: str,
267 sizes: str = "(max-width: 768px) 100vw, 50vw",
268 **attributes: Any,
269 ) -> str:
270 """Generate responsive image with multiple sizes."""
271 images = depends.get("images")
272 if not isinstance(images, CloudflareImagesAdapter):
273 return f'<img src="#{image_id}" alt="{alt}">'
275 # Generate srcset for different screen sizes
276 srcset_parts = []
277 widths = [320, 640, 768, 1024, 1280, 1536]
279 for width in widths:
280 try:
281 loop = asyncio.get_event_loop()
282 url = loop.run_until_complete(
283 images.get_image_url(
284 image_id, {"width": width, "fit": "scale-down"}
285 )
286 )
287 srcset_parts.append(f"{url} {width}w")
288 except Exception:
289 continue
291 # Build img tag with srcset
292 base_url = asyncio.get_event_loop().run_until_complete(
293 images.get_image_url(image_id, {"width": 1024, "fit": "scale-down"})
294 )
296 img_attrs = {
297 "src": base_url,
298 "alt": alt,
299 "sizes": sizes,
300 "srcset": ", ".join(srcset_parts),
301 "loading": "lazy",
302 } | attributes
304 attr_string = " ".join(
305 f'{k}="{v}"' for k, v in img_attrs.items() if v is not None
306 )
307 return f"<img {attr_string}>"
310# ACB 0.19.0+ compatibility
311__all__ = [
312 "CloudflareImagesAdapter",
313 "CloudflareImagesSettings",
314 "register_cloudflare_filters",
315]