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

1"""TwicPics adapter for FastBlocks with real-time image optimization.""" 

2 

3import asyncio 

4from contextlib import suppress 

5from typing import Any 

6from urllib.parse import quote 

7from uuid import UUID 

8 

9import httpx 

10from acb.config import Settings 

11from acb.depends import depends 

12from pydantic import SecretStr 

13 

14from ._base import ImagesBase 

15 

16 

17class TwicPicsSettings(Settings): # type: ignore[misc] 

18 """Settings for TwicPics adapter.""" 

19 

20 # Required ACB 0.19.0+ metadata 

21 MODULE_ID: UUID = UUID("01937d86-6f4c-8e5d-9a7b-c6d7e8f9a0b1") # Static UUID7 

22 MODULE_STATUS: str = "stable" 

23 

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 

28 

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 

34 

35 # Performance settings 

36 enable_lazy_loading: bool = True 

37 enable_progressive: bool = True 

38 timeout: int = 30 

39 

40 

41class TwicPicsAdapter(ImagesBase): 

42 """TwicPics adapter with real-time image optimization.""" 

43 

44 # Required ACB 0.19.0+ metadata 

45 MODULE_ID: UUID = UUID("01937d86-6f4c-8e5d-9a7b-c6d7e8f9a0b1") # Static UUID7 

46 MODULE_STATUS: str = "stable" 

47 

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 

53 

54 # Register with ACB dependency system 

55 with suppress(Exception): 

56 depends.set(self) 

57 

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() 

63 

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 ) 

69 

70 self._client = httpx.AsyncClient( 

71 timeout=self.settings.timeout, headers=headers 

72 ) 

73 return self._client 

74 

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. 

80 

81 if not self.settings: 

82 self.settings = TwicPicsSettings() 

83 

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 

87 

88 # Clean filename for URL 

89 clean_filename = quote(filename, safe=".-_") 

90 

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 

95 

96 def _build_transform_parts(self, transformations: dict[str, Any]) -> list[str]: 

97 """Build transformation parameter list for TwicPics.""" 

98 transform_parts = [] 

99 

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']}") 

105 

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}") 

112 

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}") 

122 

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]}") 

127 

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}") 

135 

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") 

148 

149 return transform_parts 

150 

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() 

157 

158 base_url = f"https://{self.settings.domain}/{image_id}" 

159 

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}" 

166 

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") 

174 

175 return f"{base_url}?twic=v1/{'/'.join(default_transforms)}" 

176 

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", {}) 

180 

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)) 

189 

190 # Build base attributes 

191 img_attrs = {"src": url, "alt": alt} | attributes 

192 

193 # Add TwicPics-specific optimizations 

194 if self.settings and self.settings.enable_lazy_loading: 

195 img_attrs["loading"] = "lazy" 

196 

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 ) 

214 

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}>" 

220 

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 } 

239 

240 base_transformations = attributes.pop("transformations", {}) 

241 

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 

254 

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 ) 

268 

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 

276 

277 if self.settings and self.settings.enable_lazy_loading: 

278 img_attrs["loading"] = "lazy" 

279 

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}>" 

284 

285 async def close(self) -> None: 

286 """Close HTTP client.""" 

287 if self._client: 

288 await self._client.aclose() 

289 self._client = None 

290 

291 

292# Template filter registration for FastBlocks 

293def register_twicpics_filters(env: Any) -> None: 

294 """Register TwicPics filters for Jinja2 templates.""" 

295 

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}" 

303 

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}">' 

311 

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}">' 

326 

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}" 

338 

339 

340# ACB 0.19.0+ compatibility 

341__all__ = ["TwicPicsAdapter", "TwicPicsSettings", "register_twicpics_filters"]