Coverage for src/blob_dict/dict/path.py: 0%

101 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-10 06:26 -0700

1import shutil 

2from abc import abstractmethod 

3from collections.abc import Iterator 

4from mimetypes import guess_type 

5from pathlib import Path 

6from typing import Any, Protocol, override 

7 

8from extratools_core.typing import PathLike 

9 

10from ..blob import BytesBlob, StrBlob 

11from ..blob.json import JsonDictBlob, YamlDictBlob 

12from . import BlobDictBase 

13 

14 

15class LocalPath(Path): 

16 def rmtree(self) -> None: 

17 shutil.rmtree(self) 

18 

19 

20class ExtraPathLike(PathLike, Protocol): 

21 @abstractmethod 

22 def rmtree(self) -> None: 

23 ... 

24 

25 

26class PathBlobDict(BlobDictBase): 

27 def __init__( 

28 self, 

29 path: ExtraPathLike, 

30 *, 

31 compression: bool = False, 

32 blob_class: type[BytesBlob] = BytesBlob, 

33 blob_class_args: dict[str, Any] | None = None, 

34 ) -> None: 

35 super().__init__() 

36 

37 if isinstance(path, Path): 

38 path = path.expanduser() 

39 

40 self.__path: ExtraPathLike = path 

41 

42 self.__compression: bool = compression 

43 

44 self.__blob_class: type[BytesBlob] = blob_class 

45 self.__blob_class_args: dict[str, Any] = blob_class_args or {} 

46 

47 def create(self) -> None: 

48 self.__path.mkdir( 

49 parents=True, 

50 exist_ok=True, 

51 ) 

52 

53 def delete(self) -> None: 

54 self.__path.rmtree() 

55 

56 @override 

57 def __contains__(self, key: str) -> bool: 

58 return (self.__path / key).is_file() 

59 

60 def __get_blob_class(self, key: str) -> type[BytesBlob]: # noqa: PLR0911 

61 mime_type: str | None 

62 mime_type, _ = guess_type(self.__path / key) 

63 

64 match mime_type: 

65 case "application/json": 

66 return JsonDictBlob 

67 case "application/octet-stream": 

68 return BytesBlob 

69 case "application/yaml": 

70 return YamlDictBlob 

71 case "audo/mpeg": 

72 # Import here as it has optional dependency 

73 from ..blob.audio import AudioBlob # noqa: PLC0415 

74 

75 return AudioBlob 

76 case "image/png": 

77 # Import here as it has optional dependency 

78 from ..blob.image import ImageBlob # noqa: PLC0415 

79 

80 return ImageBlob 

81 case ( 

82 "text/css" 

83 | "text/csv" 

84 | "text/html" 

85 | "text/javascript" 

86 | "text/markdown" 

87 | "text/plain" 

88 | "text/xml" 

89 ): 

90 return StrBlob 

91 case "video/mp4": 

92 # Import here as it has optional dependency 

93 from ..blob.video import VideoBlob # noqa: PLC0415 

94 

95 return VideoBlob 

96 case _: 

97 return self.__blob_class 

98 

99 @override 

100 def get(self, key: str, default: BytesBlob | None = None) -> BytesBlob | None: 

101 if key not in self: 

102 return default 

103 

104 blob_bytes: bytes = (self.__path / key).read_bytes() 

105 

106 blob: BytesBlob = BytesBlob.from_bytes(blob_bytes, compression=self.__compression) 

107 return blob.as_blob( 

108 self.__get_blob_class(key), 

109 self.__blob_class_args, 

110 ) 

111 

112 @override 

113 def __iter__(self) -> Iterator[str]: 

114 # The concept of relative path does not exist for `CloudPath`, 

115 # and each walked path is always absolute for `CloudPath`. 

116 # Therefore, we extract each key by removing the path prefix. 

117 # In this way, the same logic works for both absolute and relative path. 

118 prefix_len: int = ( 

119 len(str(self.__path)) 

120 # Extra 1 is for separator `/` between prefix and filename 

121 + 1 

122 ) 

123 

124 for parent, _, files in self.__path.walk(): 

125 for filename in files: 

126 yield str(parent / filename)[prefix_len:] 

127 

128 @override 

129 def clear(self) -> None: 

130 for parent, dirs, files in self.__path.walk(top_down=False): 

131 for filename in files: 

132 (parent / filename).unlink() 

133 for dirname in dirs: 

134 (parent / dirname).rmdir() 

135 

136 def __cleanup(self, key: str) -> None: 

137 (self.__path / key).unlink() 

138 

139 for parent in (self.__path / key).parents: 

140 if parent == self.__path: 

141 return 

142 

143 if parent.is_dir() and next(iter(parent.iterdir()), None) is None: 

144 parent.rmdir() 

145 

146 @override 

147 def pop(self, key: str, default: BytesBlob | None = None) -> BytesBlob | None: 

148 blob: BytesBlob | None = self.get(key) 

149 if blob: 

150 self.__cleanup(key) 

151 

152 return blob or default 

153 

154 @override 

155 def __delitem__(self, key: str) -> None: 

156 if key not in self: 

157 raise KeyError 

158 

159 self.__cleanup(key) 

160 

161 __BAD_BLOB_CLASS_ERROR_MESSAGE: str = "Must specify blob that is instance of {blob_class}" 

162 

163 @override 

164 def __setitem__(self, key: str, blob: BytesBlob) -> None: 

165 if not isinstance(blob, self.__blob_class): 

166 raise TypeError(PathBlobDict.__BAD_BLOB_CLASS_ERROR_MESSAGE.format( 

167 blob_class=self.__blob_class, 

168 )) 

169 

170 (self.__path / key).parent.mkdir( 

171 parents=True, 

172 exist_ok=True, 

173 ) 

174 

175 blob_bytes: bytes = blob.as_bytes(compression=self.__compression) 

176 (self.__path / key).write_bytes(blob_bytes)