Coverage for src/blob_dict/dict/path.py: 0%
102 statements
« prev ^ index » next coverage.py v7.8.1, created at 2025-05-21 20:53 -0700
« prev ^ index » next coverage.py v7.8.1, created at 2025-05-21 20:53 -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
8from extratools_core.typing import PathLike
10from ..blob import BytesBlob, StrBlob
11from ..blob.json import JsonDictBlob, YamlDictBlob
12from . import BlobDictBase
15class LocalPath(Path):
16 def rmtree(self) -> None:
17 shutil.rmtree(self)
20class ExtraPathLike(PathLike, Protocol):
21 @abstractmethod
22 def rmtree(self) -> None:
23 ...
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__()
37 if isinstance(path, Path):
38 path = path.expanduser()
40 self.__path: ExtraPathLike = path
42 self.__compression: bool = compression
44 self.__blob_class: type[BytesBlob] = blob_class
45 self.__blob_class_args: dict[str, Any] = blob_class_args or {}
47 def create(self) -> None:
48 self.__path.mkdir(
49 parents=True,
50 exist_ok=True,
51 )
53 def delete(self) -> None:
54 self.__path.rmtree()
56 @override
57 def __contains__(self, key: str) -> bool:
58 return (self.__path / key).is_file()
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)
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
75 return AudioBlob
76 case "image/png":
77 # Import here as it has optional dependency
78 from ..blob.image import ImageBlob # noqa: PLC0415
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
95 return VideoBlob
96 case _:
97 return self.__blob_class
99 def _get(self, key: str, blob_bytes: bytes) -> BytesBlob:
100 blob: BytesBlob = BytesBlob.from_bytes(blob_bytes, compression=self.__compression)
101 return blob.as_blob(
102 self.__get_blob_class(key),
103 self.__blob_class_args,
104 )
106 @override
107 def __getitem__(self, key: str, /) -> BytesBlob:
108 if key not in self:
109 raise KeyError
111 return self._get(key, (self.__path / key).read_bytes())
113 @override
114 def __iter__(self) -> Iterator[str]:
115 # The concept of relative path does not exist for `CloudPath`,
116 # and each walked path is always absolute for `CloudPath`.
117 # Therefore, we extract each key by removing the path prefix.
118 # In this way, the same logic works for both absolute and relative path.
119 prefix_len: int = (
120 len(str(self.__path))
121 # Extra 1 is for separator `/` between prefix and filename
122 + 1
123 )
125 for parent, _, files in self.__path.walk():
126 for filename in files:
127 yield str(parent / filename)[prefix_len:]
129 @override
130 def clear(self) -> None:
131 for parent, dirs, files in self.__path.walk(top_down=False):
132 for filename in files:
133 (parent / filename).unlink()
134 for dirname in dirs:
135 (parent / dirname).rmdir()
137 def __cleanup(self, key: str) -> None:
138 (self.__path / key).unlink()
140 for parent in (self.__path / key).parents:
141 if parent == self.__path:
142 return
144 if parent.is_dir() and next(iter(parent.iterdir()), None) is None:
145 parent.rmdir()
147 @override
148 def pop(self, key: str, /, default: BytesBlob | None = None) -> BytesBlob | None:
149 blob: BytesBlob | None = self.get(key)
150 if blob:
151 self.__cleanup(key)
153 return blob or default
155 @override
156 def __delitem__(self, key: str, /) -> None:
157 if key not in self:
158 raise KeyError
160 self.__cleanup(key)
162 __BAD_BLOB_CLASS_ERROR_MESSAGE: str = "Must specify blob that is instance of {blob_class}"
164 @override
165 def __setitem__(self, key: str, blob: BytesBlob, /) -> None:
166 if not isinstance(blob, self.__blob_class):
167 raise TypeError(PathBlobDict.__BAD_BLOB_CLASS_ERROR_MESSAGE.format(
168 blob_class=self.__blob_class,
169 ))
171 (self.__path / key).parent.mkdir(
172 parents=True,
173 exist_ok=True,
174 )
176 blob_bytes: bytes = blob.as_bytes(compression=self.__compression)
177 (self.__path / key).write_bytes(blob_bytes)