Coverage for functions \ flipdare \ core \ hash_generator.py: 95%

55 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13 

14from io import BytesIO 

15from pathlib import Path 

16 

17from google.cloud.storage.bucket import Bucket as StorageBucket 

18import blurhash as hasher 

19from PIL import Image 

20 

21from flipdare.app_log import LOG 

22from flipdare.core.storage_file_type import StorageFileType 

23from flipdare.util.file_util import FileUtil 

24 

25 

26class HashGenerator: 

27 

28 def __init__(self, bucket: StorageBucket | None, url: str, width: int, height: int) -> None: 

29 self._bucket = bucket 

30 self._width = width 

31 self._height = height 

32 self._url = url 

33 self._is_local = url.startswith(("/tmp/", "file://")) # noqa: S108 

34 

35 @classmethod 

36 def from_bucket( 

37 cls, 

38 bucket: StorageBucket, 

39 url: str, 

40 width: int, 

41 height: int, 

42 ) -> "HashGenerator": 

43 return cls(bucket=bucket, url=url, width=width, height=height) 

44 

45 @classmethod 

46 def from_local_file(cls, local_file_path: str, width: int, height: int) -> "HashGenerator": 

47 return cls(bucket=None, url=local_file_path, width=width, height=height) 

48 

49 @staticmethod 

50 def calculate_ac_components(w: int, h: int) -> tuple[int, int]: 

51 # calculate the number of components for blurhash based on aspect ratio 

52 # between 1 and 9 for x and y (9 means more detail) 

53 # baseline 83x83 pixels = 3x3 components 

54 scaled_width = max(1, min(9, round(w / 83))) 

55 scaled_height = max(1, min(9, round(h / 83))) 

56 return scaled_width, scaled_height 

57 

58 def generate_hash(self) -> str | None: 

59 image_bytes: bytes | None = None 

60 

61 if self._is_local: 

62 LOG().debug(f"Loading local image file for hash generation: {self._url}") 

63 with Path(self._url.replace("file://", "")).open("rb") as f: 

64 image_bytes = f.read() 

65 else: 

66 LOG().debug(f"Downloading image from URL for hash generation: {self._url}") 

67 if self._bucket is None: 

68 LOG().error(f"Bucket is None, cannot download image from URL: {self._url}") 

69 return None 

70 

71 from flipdare.backend.app_storage_client import AppStorageClient 

72 

73 sfs = FileUtil.create_file( 

74 gs_url=self._url, 

75 uid="hash_generator", 

76 file_type=StorageFileType.IMAGE, 

77 ) 

78 storage_util = AppStorageClient(self._bucket) 

79 image_bytes = storage_util.download_to_memory(sfs) 

80 

81 if image_bytes is None: 

82 LOG().error(f"Failed to download image data for URL: {self._url}") 

83 return None 

84 

85 image_stream = BytesIO(image_bytes) 

86 w = self._width 

87 h = self._height 

88 

89 img = Image.open(image_stream) 

90 img_copy = img.copy() 

91 img_copy = img_copy.convert("RGB") # Ensure image is in RGB format 

92 thumbnail_size = (w, h) 

93 img_copy.thumbnail(thumbnail_size) 

94 

95 x_comp, y_comp = self.calculate_ac_components(w, h) 

96 hash_value = hasher.encode(img_copy, x_comp, y_comp) 

97 

98 LOG().info(f"Generated blurhash: {hash_value} IMG: {w}x{h} AR: {x_comp}:{y_comp}") 

99 return str(hash_value)