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

82 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-03-29 08:37 -0700

1import shutil 

2from collections.abc import Iterator 

3from pathlib import Path 

4from typing import Any, override 

5 

6from cloudpathlib import CloudPath 

7 

8from ..blob import BytesBlob, StrBlob 

9from ..blob.json import JsonDictBlob 

10from . import BlobDictBase 

11 

12 

13class LocalPath(Path): 

14 def rmtree(self) -> None: 

15 shutil.rmtree(self) 

16 

17 

18class PathBlobDict(BlobDictBase): 

19 def __init__( 

20 self, 

21 path: LocalPath | CloudPath, 

22 *, 

23 compression: bool = False, 

24 blob_class: type[BytesBlob] = BytesBlob, 

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

26 ) -> None: 

27 super().__init__() 

28 

29 self.__path: LocalPath | CloudPath = path 

30 

31 self.__compression: bool = compression 

32 

33 self.__blob_class: type[BytesBlob] = blob_class 

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

35 

36 def create(self) -> None: 

37 self.__path.mkdir( 

38 parents=True, 

39 exist_ok=True, 

40 ) 

41 

42 def delete(self) -> None: 

43 self.__path.rmtree() 

44 

45 @override 

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

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

48 

49 def __get_blob_class(self, key: str) -> type[BytesBlob]: 

50 match (self.__path / key).suffix.lower(): 

51 case ".json": 

52 return JsonDictBlob 

53 case ".png": 

54 # Import here as it has optional dependency 

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

56 

57 return ImageBlob 

58 # Common text file extensions 

59 # https://en.wikipedia.org/wiki/List_of_file_formats 

60 case ( 

61 ".asc" 

62 | ".bib" 

63 | ".cfg" 

64 | ".cnf" 

65 | ".conf" 

66 | ".csv" 

67 | ".diff" 

68 | ".htm" 

69 | ".html" 

70 | ".ini" 

71 | ".log" 

72 | ".markdown" 

73 | ".md" 

74 | ".tex" 

75 | ".text" 

76 | ".toml" 

77 | ".tsv" 

78 | ".txt" 

79 | ".xhtml" 

80 | ".xht" 

81 | ".xml" 

82 | ".yaml" 

83 | ".yml" 

84 ): 

85 return StrBlob 

86 case _: 

87 return self.__blob_class 

88 

89 @override 

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

91 if key not in self: 

92 return default 

93 

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

95 

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

97 return blob.as_blob( 

98 self.__get_blob_class(key), 

99 self.__blob_class_args, 

100 ) 

101 

102 @override 

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

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

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

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

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

108 prefix_len: int = ( 

109 len(str(self.__path)) 

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

111 + 1 

112 ) 

113 

114 for parent, _, files in self.__path.walk(top_down=False): 

115 for filename in files: 

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

117 

118 @override 

119 def clear(self) -> None: 

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

121 for filename in files: 

122 (parent / filename).unlink() 

123 for dirname in dirs: 

124 (parent / dirname).rmdir() 

125 

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

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

128 

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

130 if parent == self.__path: 

131 return 

132 

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

134 parent.rmdir() 

135 

136 @override 

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

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

139 if blob: 

140 self.__cleanup(key) 

141 

142 return blob or default 

143 

144 @override 

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

146 if key not in self: 

147 raise KeyError 

148 

149 self.__cleanup(key) 

150 

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

152 

153 @override 

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

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

156 raise TypeError(PathBlobDict.__BAD_BLOB_CLASS_ERROR_MESSAGE.format( 

157 blob_class=self.__blob_class, 

158 )) 

159 

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

161 parents=True, 

162 exist_ok=True, 

163 ) 

164 

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

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