Coverage for fastblocks / adapters / images / twicpics.py: 26%
167 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
1"""TwicPics adapter for FastBlocks with real-time image optimization."""
3import asyncio
4from contextlib import suppress
5from typing import Any
6from urllib.parse import quote
7from uuid import UUID
9import httpx
10from acb.depends import depends
11from pydantic import SecretStr
13from ._base import ImagesBase, ImagesBaseSettings
16class TwicPicsImagesSettings(ImagesBaseSettings):
17 """Settings for TwicPics adapter."""
19 # Required ACB 0.19.0+ metadata
20 MODULE_ID: UUID = UUID("01937d86-6f4c-8e5d-9a7b-c6d7e8f9a0b1") # Static UUID7
21 MODULE_STATUS: str = "stable"
23 # TwicPics configuration
24 domain: str = "" # Your TwicPics domain (e.g., "demo.twic.pics")
25 path_prefix: str = "" # Optional path prefix
26 api_key: SecretStr = SecretStr("") # For upload operations
28 # Image optimization defaults
29 default_quality: int = 85
30 default_format: str = "auto" # auto, webp, avif, jpeg, png
31 enable_placeholder: bool = True
32 placeholder_quality: int = 10
34 # Performance settings
35 enable_lazy_loading: bool = True
36 enable_progressive: bool = True
37 timeout: int = 30
40class TwicPicsImages(ImagesBase):
41 """TwicPics adapter with real-time image optimization."""
43 # Required ACB 0.19.0+ metadata
44 MODULE_ID: UUID = UUID("01937d86-6f4c-8e5d-9a7b-c6d7e8f9a0b1") # Static UUID7
45 MODULE_STATUS: str = "stable"
47 def __init__(self) -> None:
48 """Initialize TwicPics adapter."""
49 super().__init__()
50 self.settings: TwicPicsImagesSettings | None = None
51 self._client: httpx.AsyncClient | None = None
53 # Register with ACB dependency system
54 with suppress(Exception):
55 depends.set(self)
57 async def _get_client(self) -> httpx.AsyncClient:
58 """Get or create HTTP client."""
59 if not self._client:
60 if not self.settings:
61 self.settings = TwicPicsImagesSettings()
63 headers = {}
64 if self.settings.api_key.get_secret_value():
65 headers["Authorization"] = (
66 f"Bearer {self.settings.api_key.get_secret_value()}"
67 )
69 self._client = httpx.AsyncClient(
70 timeout=self.settings.timeout, headers=headers
71 )
72 return self._client
74 async def upload_image(self, file_data: bytes, filename: str) -> str:
75 """Upload image to TwicPics (or return reference path)."""
76 # Note: TwicPics typically works with existing images via URL references
77 # For upload scenarios, you'd typically upload to your own storage
78 # and then reference through TwicPics. This is a simplified implementation.
80 if not self.settings:
81 self.settings = TwicPicsImagesSettings()
83 # For demo purposes, we'll create a reference based on filename
84 # In real implementation, you'd upload to your storage backend
85 # and return the path that TwicPics can access
87 # Clean filename for URL
88 clean_filename = quote(filename, safe=".-_")
90 # Return the path that will be used with TwicPics
91 if self.settings.path_prefix:
92 return f"{self.settings.path_prefix}/{clean_filename}"
93 return clean_filename
95 def _build_transform_parts(self, transformations: dict[str, Any]) -> list[str]:
96 """Build transformation parameter list for TwicPics."""
97 transform_parts = []
99 # Resize transformations
100 if "width" in transformations:
101 transform_parts.append(f"width={transformations['width']}")
102 if "height" in transformations:
103 transform_parts.append(f"height={transformations['height']}")
105 # Fit modes
106 if "fit" in transformations:
107 fit_mode = transformations["fit"]
108 resize_map = {"crop": "fill", "contain": "contain", "cover": "cover"}
109 resize_value = resize_map.get(fit_mode, fit_mode)
110 transform_parts.append(f"resize={resize_value}")
112 # Quality and format
113 transform_parts.append(
114 f"quality={transformations.get('quality', self.settings.default_quality if self.settings else 80)}"
115 )
116 output_format = transformations.get(
117 "format", self.settings.default_format if self.settings else "auto"
118 )
119 if output_format != "auto":
120 transform_parts.append(f"output={output_format}")
122 # Advanced effects
123 for effect in ("blur", "brightness", "contrast", "saturation", "rotate"):
124 if effect in transformations:
125 transform_parts.append(f"{effect}={transformations[effect]}")
127 # Focus point
128 if "focus" in transformations:
129 focus = transformations["focus"]
130 if isinstance(focus, dict) and "x" in focus and "y" in focus:
131 transform_parts.append(f"focus={focus['x']}x{focus['y']}")
132 elif isinstance(focus, str):
133 transform_parts.append(f"focus={focus}")
135 # Progressive JPEG
136 if (
137 self.settings
138 and self.settings.enable_progressive
139 and output_format
140 in (
141 "jpeg",
142 "jpg",
143 "auto",
144 )
145 ):
146 transform_parts.append("progressive=true")
148 return transform_parts
150 async def get_image_url(
151 self, image_id: str, transformations: dict[str, Any] | None = None
152 ) -> str:
153 """Generate TwicPics URL with real-time transformations."""
154 if not self.settings:
155 self.settings = TwicPicsImagesSettings()
157 base_url = f"https://{self.settings.domain}/{image_id}"
159 # Apply transformations if provided
160 if transformations:
161 transform_parts = self._build_transform_parts(transformations)
162 if transform_parts:
163 transform_string = "/".join(transform_parts)
164 return f"{base_url}?twic=v1/{transform_string}"
166 # Default optimizations
167 default_transforms = [
168 f"quality={self.settings.default_quality}",
169 f"output={self.settings.default_format}",
170 ]
171 if self.settings.enable_progressive:
172 default_transforms.append("progressive=true")
174 return f"{base_url}?twic=v1/{'/'.join(default_transforms)}"
176 def get_img_tag(self, image_id: str, alt: str, **attributes: Any) -> str:
177 """Generate img tag with TwicPics patterns and optimization."""
178 transformations = attributes.pop("transformations", {})
180 # Generate optimized URL
181 try:
182 loop = asyncio.get_event_loop()
183 url = loop.run_until_complete(self.get_image_url(image_id, transformations))
184 except RuntimeError:
185 loop = asyncio.new_event_loop()
186 asyncio.set_event_loop(loop)
187 url = loop.run_until_complete(self.get_image_url(image_id, transformations))
189 # Build base attributes
190 img_attrs = {"src": url, "alt": alt} | attributes
192 # Add TwicPics-specific optimizations
193 if self.settings and self.settings.enable_lazy_loading:
194 img_attrs["loading"] = "lazy"
196 # Add placeholder for better UX
197 if self.settings and self.settings.enable_placeholder:
198 placeholder_transforms = {
199 **transformations,
200 "quality": self.settings.placeholder_quality,
201 "width": 20, # Very small placeholder
202 }
203 with suppress(Exception): # Fallback to regular loading
204 placeholder_url = loop.run_until_complete(
205 self.get_image_url(image_id, placeholder_transforms)
206 )
207 # For TwicPics, you might use data-src for lazy loading
208 img_attrs["data-src"] = img_attrs["src"]
209 img_attrs["src"] = placeholder_url
210 img_attrs["class"] = (
211 f"{img_attrs.get('class', '')} twicpics-lazy".strip()
212 )
214 # Generate tag
215 attr_string = " ".join(
216 f'{k}="{v}"' for k, v in img_attrs.items() if v is not None
217 )
218 return f"<img {attr_string}>"
220 def get_responsive_img_tag(
221 self,
222 image_id: str,
223 alt: str,
224 breakpoints: dict[str, dict[str, Any]] | None = None,
225 **attributes: Any,
226 ) -> str:
227 """Generate responsive img tag with TwicPics breakpoint optimization."""
228 if not breakpoints:
229 # Default responsive breakpoints
230 breakpoints = {
231 "320w": {"width": 320},
232 "640w": {"width": 640},
233 "768w": {"width": 768},
234 "1024w": {"width": 1024},
235 "1280w": {"width": 1280},
236 "1536w": {"width": 1536},
237 }
239 base_transformations = attributes.pop("transformations", {})
241 # Generate srcset
242 srcset_parts = []
243 for descriptor, transforms in breakpoints.items():
244 combined_transforms = base_transformations | transforms
245 try:
246 loop = asyncio.get_event_loop()
247 url = loop.run_until_complete(
248 self.get_image_url(image_id, combined_transforms)
249 )
250 srcset_parts.append(f"{url} {descriptor}")
251 except Exception:
252 continue
254 # Default src (largest size)
255 default_transforms = {**base_transformations, "width": 1024}
256 try:
257 loop = asyncio.get_event_loop()
258 default_src = loop.run_until_complete(
259 self.get_image_url(image_id, default_transforms)
260 )
261 except RuntimeError:
262 loop = asyncio.new_event_loop()
263 asyncio.set_event_loop(loop)
264 default_src = loop.run_until_complete(
265 self.get_image_url(image_id, default_transforms)
266 )
268 # Build responsive img tag
269 img_attrs = {
270 "src": default_src,
271 "srcset": ", ".join(srcset_parts),
272 "alt": alt,
273 "sizes": attributes.pop("sizes", "(max-width: 768px) 100vw, 50vw"),
274 } | attributes
276 if self.settings and self.settings.enable_lazy_loading:
277 img_attrs["loading"] = "lazy"
279 attr_string = " ".join(
280 f'{k}="{v}"' for k, v in img_attrs.items() if v is not None
281 )
282 return f"<img {attr_string}>"
284 async def close(self) -> None:
285 """Close HTTP client."""
286 if self._client:
287 await self._client.aclose()
288 self._client = None
291# Template filter registration for FastBlocks
292def register_twicpics_filters(env: Any) -> None:
293 """Register TwicPics filters for Jinja2 templates."""
295 @env.filter("twic_url") # type: ignore[misc]
296 async def twic_url_filter(image_id: str, **transformations: Any) -> str:
297 """Template filter for TwicPics URLs."""
298 images = await depends.get("images")
299 if isinstance(images, TwicPicsImages):
300 return await images.get_image_url(image_id, transformations)
301 return f"#{image_id}"
303 @env.filter("twic_img") # type: ignore[misc]
304 def twic_img_filter(image_id: str, alt: str = "", **attributes: Any) -> str:
305 """Template filter for TwicPics img tags."""
306 images = depends.get_sync("images")
307 if isinstance(images, TwicPicsImages):
308 return images.get_img_tag(image_id, alt, **attributes)
309 return f'<img src="#{image_id}" alt="{alt}">'
311 @env.global_("twicpics_responsive") # type: ignore[misc]
312 def twicpics_responsive(
313 image_id: str,
314 alt: str,
315 breakpoints: dict[str, dict[str, Any]] | None = None,
316 **attributes: Any,
317 ) -> str:
318 """Generate responsive image with TwicPics optimization."""
319 images = depends.get_sync("images")
320 if isinstance(images, TwicPicsImages):
321 return images.get_responsive_img_tag(
322 image_id, alt, breakpoints, **attributes
323 )
324 return f'<img src="#{image_id}" alt="{alt}">'
326 @env.filter("twic_placeholder") # type: ignore[misc]
327 async def twic_placeholder_filter(
328 image_id: str, width: int = 20, quality: int = 10
329 ) -> str:
330 """Generate ultra-low quality placeholder URL."""
331 images = await depends.get("images")
332 if isinstance(images, TwicPicsImages):
333 return await images.get_image_url(
334 image_id, {"width": width, "quality": quality}
335 )
336 return f"#{image_id}"
339ImagesSettings = TwicPicsImagesSettings
340Images = TwicPicsImages
342depends.set(Images, "twicpics")
344# ACB 0.19.0+ compatibility
345__all__ = [
346 "TwicPicsImages",
347 "TwicPicsImagesSettings",
348 "register_twicpics_filters",
349 "Images",
350 "ImagesSettings",
351]