Coverage for src/photo_path_utils.py: 100%

36 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-16 04:41 +0000

1"""Photo path utils 

2 Extract filename and extension from photo. 

3 

4 Args: 

5 photo: Photo object from iCloudPy 

6 file_size: File size variant (original, medium, thumb, etc.) 

7 

8 Returns: 

9 Tuple of (name, extension) where name is filename without extension 

10 and extension is the file extension. 

11 

12This module contains utilities for generating photo file paths and managing 

13file naming conventions for photo synchronization. 

14""" 

15 

16___author___ = "Mandar Patil <mandarons@pm.me>" 

17 

18import base64 

19import os 

20import unicodedata 

21 

22from src import get_logger 

23 

24LOGGER = get_logger() 

25 

26 

27def get_photo_name_and_extension(photo, file_size: str) -> tuple[str, str]: 

28 """Extract filename and extension from photo. 

29 

30 Args: 

31 photo: Photo object from iCloudPy 

32 file_size: File size variant (original, medium, thumb, etc.) 

33 

34 Returns: 

35 Tuple of (name, extension) where name is filename without extension 

36 and extension is the file extension 

37 """ 

38 filename = photo.filename 

39 name, extension = filename.rsplit(".", 1) if "." in filename else [filename, ""] 

40 

41 # Handle original_alt file type mapping 

42 if file_size == "original_alt" and file_size in photo.versions: 

43 filetype = photo.versions[file_size]["type"] 

44 if filetype in _get_original_alt_filetype_mapping(): 

45 extension = _get_original_alt_filetype_mapping()[filetype] 

46 else: 

47 LOGGER.warning(f"Unknown filetype {filetype} for original_alt version of {filename}") 

48 

49 return name, extension 

50 

51 

52def generate_photo_filename_with_metadata(photo, file_size: str) -> str: 

53 """Generate filename with file size and photo ID metadata. 

54 

55 Args: 

56 photo: Photo object from iCloudPy 

57 file_size: File size variant (original, medium, thumb, etc.) 

58 

59 Returns: 

60 Filename string with format: name__filesize__base64id.extension 

61 """ 

62 name, extension = get_photo_name_and_extension(photo, file_size) 

63 photo_id_encoded = base64.urlsafe_b64encode(photo.id.encode()).decode() 

64 

65 if extension == "": 

66 return f"{'__'.join([name, file_size, photo_id_encoded])}" 

67 else: 

68 return f"{'__'.join([name, file_size, photo_id_encoded])}.{extension}" 

69 

70 

71def create_folder_path_if_needed(destination_path: str, folder_format: str | None, photo) -> str: 

72 """Create folder path based on folder format and photo creation date. 

73 

74 Args: 

75 destination_path: Base destination path 

76 folder_format: strftime format string for folder creation (e.g., "%Y/%m") 

77 photo: Photo object with created date 

78 

79 Returns: 

80 Full destination path including created folder if folder_format is specified 

81 """ 

82 if folder_format is None: 

83 return destination_path 

84 

85 folder = photo.created.strftime(folder_format) 

86 full_destination = os.path.join(destination_path, folder) 

87 os.makedirs(full_destination, exist_ok=True) 

88 return full_destination 

89 

90 

91def normalize_file_path(file_path: str) -> str: 

92 """Normalize file path using Unicode NFC normalization. 

93 

94 Args: 

95 file_path: File path to normalize 

96 

97 Returns: 

98 Normalized file path 

99 """ 

100 return unicodedata.normalize("NFC", file_path) 

101 

102 

103def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None: 

104 """Rename legacy file format to new format if it exists. 

105 

106 Args: 

107 old_path: Path to legacy file format 

108 new_path: Path to new file format 

109 """ 

110 import os 

111 

112 if os.path.isfile(old_path): 

113 os.rename(old_path, new_path) 

114 

115 

116def _get_original_alt_filetype_mapping() -> dict: 

117 """Get mapping of original_alt file types to extensions. 

118 

119 Returns: 

120 Dictionary mapping file types to extensions 

121 """ 

122 return { 

123 "public.png": "png", 

124 "public.jpeg": "jpeg", 

125 "public.heic": "heic", 

126 "public.image": "HEIC", 

127 "com.sony.arw-raw-image": "arw", 

128 "org.webmproject.webp": "webp", 

129 "com.compuserve.gif": "gif", 

130 "com.adobe.raw-image": "dng", 

131 "public.tiff": "tiff", 

132 "public.jpeg-2000": "jp2", 

133 "com.truevision.tga-image": "tga", 

134 "com.sgi.sgi-image": "sgi", 

135 "com.adobe.photoshop-image": "psd", 

136 "public.pbm": "pbm", 

137 "public.heif": "heif", 

138 "com.microsoft.bmp": "bmp", 

139 "com.fuji.raw-image": "raf", 

140 "com.canon.cr2-raw-image": "cr2", 

141 "com.panasonic.rw2-raw-image": "rw2", 

142 "com.nikon.nrw-raw-image": "nrw", 

143 "com.pentax.raw-image": "pef", 

144 "com.nikon.raw-image": "nef", 

145 "com.olympus.raw-image": "orf", 

146 "com.adobe.pdf": "pdf", 

147 "com.canon.cr3-raw-image": "cr3", 

148 "com.olympus.or-raw-image": "orf", 

149 "public.mpo-image": "mpo", 

150 "com.dji.mimo.pano.jpeg": "jpg", 

151 "public.avif": "avif", 

152 "com.canon.crw-raw-image": "crw", 

153 }