Coverage for fastblocks/adapters/images/twicpics.py: 0%
165 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"""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.config import Settings
11from acb.depends import depends
12from pydantic import SecretStr
14from ._base import ImagesBase
17class TwicPicsSettings(Settings): # type: ignore[misc]
18 """Settings for TwicPics adapter."""
20 # Required ACB 0.19.0+ metadata
21 MODULE_ID: UUID = UUID("01937d86-6f4c-8e5d-9a7b-c6d7e8f9a0b1") # Static UUID7
22 MODULE_STATUS: str = "stable"
24 # TwicPics configuration
25 domain: str = "" # Your TwicPics domain (e.g., "demo.twic.pics")
26 path_prefix: str = "" # Optional path prefix
27 api_key: SecretStr = SecretStr("") # For upload operations
29 # Image optimization defaults
30 default_quality: int = 85
31 default_format: str = "auto" # auto, webp, avif, jpeg, png
32 enable_placeholder: bool = True
33 placeholder_quality: int = 10
35 # Performance settings
36 enable_lazy_loading: bool = True
37 enable_progressive: bool = True
38 timeout: int = 30
41class TwicPicsAdapter(ImagesBase):
42 """TwicPics adapter with real-time image optimization."""
44 # Required ACB 0.19.0+ metadata
45 MODULE_ID: UUID = UUID("01937d86-6f4c-8e5d-9a7b-c6d7e8f9a0b1") # Static UUID7
46 MODULE_STATUS: str = "stable"
48 def __init__(self) -> None:
49 """Initialize TwicPics adapter."""
50 super().__init__()
51 self.settings: TwicPicsSettings | None = None
52 self._client: httpx.AsyncClient | None = None
54 # Register with ACB dependency system
55 with suppress(Exception):
56 depends.set(self)
58 async def _get_client(self) -> httpx.AsyncClient:
59 """Get or create HTTP client."""
60 if not self._client:
61 if not self.settings:
62 self.settings = TwicPicsSettings()
64 headers = {}
65 if self.settings.api_key.get_secret_value():
66 headers["Authorization"] = (
67 f"Bearer {self.settings.api_key.get_secret_value()}"
68 )
70 self._client = httpx.AsyncClient(
71 timeout=self.settings.timeout, headers=headers
72 )
73 return self._client
75 async def upload_image(self, file_data: bytes, filename: str) -> str:
76 """Upload image to TwicPics (or return reference path)."""
77 # Note: TwicPics typically works with existing images via URL references
78 # For upload scenarios, you'd typically upload to your own storage
79 # and then reference through TwicPics. This is a simplified implementation.
81 if not self.settings:
82 self.settings = TwicPicsSettings()
84 # For demo purposes, we'll create a reference based on filename
85 # In real implementation, you'd upload to your storage backend
86 # and return the path that TwicPics can access
88 # Clean filename for URL
89 clean_filename = quote(filename, safe=".-_")
91 # Return the path that will be used with TwicPics
92 if self.settings.path_prefix:
93 return f"{self.settings.path_prefix}/{clean_filename}"
94 return clean_filename
96 def _build_transform_parts(self, transformations: dict[str, Any]) -> list[str]:
97 """Build transformation parameter list for TwicPics."""
98 transform_parts = []
100 # Resize transformations
101 if "width" in transformations:
102 transform_parts.append(f"width={transformations['width']}")
103 if "height" in transformations:
104 transform_parts.append(f"height={transformations['height']}")
106 # Fit modes
107 if "fit" in transformations:
108 fit_mode = transformations["fit"]
109 resize_map = {"crop": "fill", "contain": "contain", "cover": "cover"}
110 resize_value = resize_map.get(fit_mode, fit_mode)
111 transform_parts.append(f"resize={resize_value}")
113 # Quality and format
114 transform_parts.append(
115 f"quality={transformations.get('quality', self.settings.default_quality if self.settings else 80)}"
116 )
117 output_format = transformations.get(
118 "format", self.settings.default_format if self.settings else "auto"
119 )
120 if output_format != "auto":
121 transform_parts.append(f"output={output_format}")
123 # Advanced effects
124 for effect in ("blur", "brightness", "contrast", "saturation", "rotate"):
125 if effect in transformations:
126 transform_parts.append(f"{effect}={transformations[effect]}")
128 # Focus point
129 if "focus" in transformations:
130 focus = transformations["focus"]
131 if isinstance(focus, dict) and "x" in focus and "y" in focus:
132 transform_parts.append(f"focus={focus['x']}x{focus['y']}")
133 elif isinstance(focus, str):
134 transform_parts.append(f"focus={focus}")
136 # Progressive JPEG
137 if (
138 self.settings
139 and self.settings.enable_progressive
140 and output_format
141 in (
142 "jpeg",
143 "jpg",
144 "auto",
145 )
146 ):
147 transform_parts.append("progressive=true")
149 return transform_parts
151 async def get_image_url(
152 self, image_id: str, transformations: dict[str, Any] | None = None
153 ) -> str:
154 """Generate TwicPics URL with real-time transformations."""
155 if not self.settings:
156 self.settings = TwicPicsSettings()
158 base_url = f"https://{self.settings.domain}/{image_id}"
160 # Apply transformations if provided
161 if transformations:
162 transform_parts = self._build_transform_parts(transformations)
163 if transform_parts:
164 transform_string = "/".join(transform_parts)
165 return f"{base_url}?twic=v1/{transform_string}"
167 # Default optimizations
168 default_transforms = [
169 f"quality={self.settings.default_quality}",
170 f"output={self.settings.default_format}",
171 ]
172 if self.settings.enable_progressive:
173 default_transforms.append("progressive=true")
175 return f"{base_url}?twic=v1/{'/'.join(default_transforms)}"
177 def get_img_tag(self, image_id: str, alt: str, **attributes: Any) -> str:
178 """Generate img tag with TwicPics patterns and optimization."""
179 transformations = attributes.pop("transformations", {})
181 # Generate optimized URL
182 try:
183 loop = asyncio.get_event_loop()
184 url = loop.run_until_complete(self.get_image_url(image_id, transformations))
185 except RuntimeError:
186 loop = asyncio.new_event_loop()
187 asyncio.set_event_loop(loop)
188 url = loop.run_until_complete(self.get_image_url(image_id, transformations))
190 # Build base attributes
191 img_attrs = {"src": url, "alt": alt} | attributes
193 # Add TwicPics-specific optimizations
194 if self.settings and self.settings.enable_lazy_loading:
195 img_attrs["loading"] = "lazy"
197 # Add placeholder for better UX
198 if self.settings and self.settings.enable_placeholder:
199 placeholder_transforms = {
200 **transformations,
201 "quality": self.settings.placeholder_quality,
202 "width": 20, # Very small placeholder
203 }
204 with suppress(Exception): # Fallback to regular loading
205 placeholder_url = loop.run_until_complete(
206 self.get_image_url(image_id, placeholder_transforms)
207 )
208 # For TwicPics, you might use data-src for lazy loading
209 img_attrs["data-src"] = img_attrs["src"]
210 img_attrs["src"] = placeholder_url
211 img_attrs["class"] = (
212 f"{img_attrs.get('class', '')} twicpics-lazy".strip()
213 )
215 # Generate tag
216 attr_string = " ".join(
217 f'{k}="{v}"' for k, v in img_attrs.items() if v is not None
218 )
219 return f"<img {attr_string}>"
221 def get_responsive_img_tag(
222 self,
223 image_id: str,
224 alt: str,
225 breakpoints: dict[str, dict[str, Any]] | None = None,
226 **attributes: Any,
227 ) -> str:
228 """Generate responsive img tag with TwicPics breakpoint optimization."""
229 if not breakpoints:
230 # Default responsive breakpoints
231 breakpoints = {
232 "320w": {"width": 320},
233 "640w": {"width": 640},
234 "768w": {"width": 768},
235 "1024w": {"width": 1024},
236 "1280w": {"width": 1280},
237 "1536w": {"width": 1536},
238 }
240 base_transformations = attributes.pop("transformations", {})
242 # Generate srcset
243 srcset_parts = []
244 for descriptor, transforms in breakpoints.items():
245 combined_transforms = base_transformations | transforms
246 try:
247 loop = asyncio.get_event_loop()
248 url = loop.run_until_complete(
249 self.get_image_url(image_id, combined_transforms)
250 )
251 srcset_parts.append(f"{url} {descriptor}")
252 except Exception:
253 continue
255 # Default src (largest size)
256 default_transforms = {**base_transformations, "width": 1024}
257 try:
258 loop = asyncio.get_event_loop()
259 default_src = loop.run_until_complete(
260 self.get_image_url(image_id, default_transforms)
261 )
262 except RuntimeError:
263 loop = asyncio.new_event_loop()
264 asyncio.set_event_loop(loop)
265 default_src = loop.run_until_complete(
266 self.get_image_url(image_id, default_transforms)
267 )
269 # Build responsive img tag
270 img_attrs = {
271 "src": default_src,
272 "srcset": ", ".join(srcset_parts),
273 "alt": alt,
274 "sizes": attributes.pop("sizes", "(max-width: 768px) 100vw, 50vw"),
275 } | attributes
277 if self.settings and self.settings.enable_lazy_loading:
278 img_attrs["loading"] = "lazy"
280 attr_string = " ".join(
281 f'{k}="{v}"' for k, v in img_attrs.items() if v is not None
282 )
283 return f"<img {attr_string}>"
285 async def close(self) -> None:
286 """Close HTTP client."""
287 if self._client:
288 await self._client.aclose()
289 self._client = None
292# Template filter registration for FastBlocks
293def register_twicpics_filters(env: Any) -> None:
294 """Register TwicPics filters for Jinja2 templates."""
296 @env.filter("twic_url") # type: ignore[misc]
297 async def twic_url_filter(image_id: str, **transformations: Any) -> str:
298 """Template filter for TwicPics URLs."""
299 images = depends.get("images")
300 if isinstance(images, TwicPicsAdapter):
301 return await images.get_image_url(image_id, transformations)
302 return f"#{image_id}"
304 @env.filter("twic_img") # type: ignore[misc]
305 def twic_img_filter(image_id: str, alt: str = "", **attributes: Any) -> str:
306 """Template filter for TwicPics img tags."""
307 images = depends.get("images")
308 if isinstance(images, TwicPicsAdapter):
309 return images.get_img_tag(image_id, alt, **attributes)
310 return f'<img src="#{image_id}" alt="{alt}">'
312 @env.global_("twicpics_responsive") # type: ignore[misc]
313 def twicpics_responsive(
314 image_id: str,
315 alt: str,
316 breakpoints: dict[str, dict[str, Any]] | None = None,
317 **attributes: Any,
318 ) -> str:
319 """Generate responsive image with TwicPics optimization."""
320 images = depends.get("images")
321 if isinstance(images, TwicPicsAdapter):
322 return images.get_responsive_img_tag(
323 image_id, alt, breakpoints, **attributes
324 )
325 return f'<img src="#{image_id}" alt="{alt}">'
327 @env.filter("twic_placeholder") # type: ignore[misc]
328 async def twic_placeholder_filter(
329 image_id: str, width: int = 20, quality: int = 10
330 ) -> str:
331 """Generate ultra-low quality placeholder URL."""
332 images = depends.get("images")
333 if isinstance(images, TwicPicsAdapter):
334 return await images.get_image_url(
335 image_id, {"width": width, "quality": quality}
336 )
337 return f"#{image_id}"
340# ACB 0.19.0+ compatibility
341__all__ = ["TwicPicsAdapter", "TwicPicsSettings", "register_twicpics_filters"]