shortbox 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shortbox/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ __all__ = ["Comic"]
2
+ __version__ = "0.4.0"
3
+
4
+ from shortbox.comic import Comic
@@ -0,0 +1,27 @@
1
+ __all__ = ["Archive", "TarArchive", "ZipArchive"]
2
+
3
+ from importlib.util import find_spec
4
+
5
+ from shortbox.archives.base import Archive
6
+ from shortbox.archives.tar import TarArchive
7
+ from shortbox.archives.zip import ZipArchive
8
+ from shortbox.registries import archive_registry
9
+
10
+ archive_registry.register(TarArchive, ".tar")
11
+ archive_registry.register(ZipArchive, ".zip")
12
+
13
+ if find_spec("pdffile") is not None:
14
+ from shortbox.archives.pdf import PdfArchive
15
+
16
+ archive_registry.register(PdfArchive)
17
+ __all__ += ("PdfArchive",)
18
+ if find_spec("rarfile") is not None:
19
+ from shortbox.archives.rar import RarArchive
20
+
21
+ archive_registry.register(RarArchive, ".rar")
22
+ __all__ += ("RarArchive",)
23
+ if find_spec("py7zr") is not None:
24
+ from shortbox.archives.sevenzip import SevenZipArchive
25
+
26
+ archive_registry.register(SevenZipArchive, ".7z")
27
+ __all__ += ("SevenZipArchive",)
@@ -0,0 +1,39 @@
1
+ __all__ = ["Archive"]
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+
8
+ class Archive(ABC):
9
+ extension: ClassVar[str]
10
+ can_create: ClassVar[bool]
11
+
12
+ def __init__(self, file: Path):
13
+ self.file = file
14
+
15
+ @classmethod
16
+ @abstractmethod
17
+ def probe(cls, file: Path) -> bool: ...
18
+
19
+ @abstractmethod
20
+ def extract(self, filenames: list[str], destination: Path) -> None: ...
21
+
22
+ @abstractmethod
23
+ def list_filenames(self) -> list[str]: ...
24
+
25
+ @abstractmethod
26
+ def read_file(self, filename: str) -> bytes: ...
27
+
28
+ @abstractmethod
29
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None: ...
30
+
31
+ @abstractmethod
32
+ def remove_file(self, filename: str) -> None: ...
33
+
34
+ @abstractmethod
35
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: ...
36
+
37
+ @classmethod
38
+ @abstractmethod
39
+ def archive_files(cls, parent: Path, name: str, files: list[Path]) -> Path: ...
@@ -0,0 +1,82 @@
1
+ __all__ = ["PdfArchive"]
2
+
3
+ from pathlib import Path
4
+ from typing import ClassVar
5
+
6
+ from natsort import humansorted, ns
7
+ from pdffile import PDFFile
8
+
9
+ from shortbox.archives.base import Archive
10
+ from shortbox.errors import ArchiveCapabilityError, ArchiveError
11
+
12
+
13
+ class PdfArchive(Archive):
14
+ extension: ClassVar[str] = ".pdf"
15
+ can_create: ClassVar[bool] = False
16
+
17
+ @classmethod
18
+ def probe(cls, file: Path) -> bool:
19
+ return PDFFile.is_pdffile(str(file))
20
+
21
+ def extract(self, filenames: list[str], destination: Path) -> None:
22
+ try:
23
+ with PDFFile(path=self.file) as archive:
24
+ for filename in filenames:
25
+ try:
26
+ int(filename)
27
+ props: dict = {}
28
+ data = archive.read(filename, fmt="pixmap_jpeg", props=props)
29
+ target = destination / f"{filename}.{props['ext']}"
30
+ except ValueError:
31
+ data = archive.read(filename)
32
+ target = destination / filename
33
+ target.parent.mkdir(parents=True, exist_ok=True)
34
+ target.write_bytes(data)
35
+ except Exception as err:
36
+ raise ArchiveError(
37
+ f"Unable to extract files from {self.file.name} to {destination}."
38
+ ) from err
39
+
40
+ def list_filenames(self) -> list[str]:
41
+ try:
42
+ with PDFFile(path=self.file) as archive:
43
+ return humansorted(archive.namelist(), alg=ns.G | ns.P)
44
+ except Exception as err:
45
+ raise ArchiveError(f"Unable to list files from {self.file.name}.") from err
46
+
47
+ def read_file(self, filename: str) -> bytes:
48
+ try:
49
+ with PDFFile(path=self.file) as archive:
50
+ try:
51
+ int(filename)
52
+ return archive.read(filename, fmt="pixmap_jpeg")
53
+ except ValueError:
54
+ return archive.read(filename)
55
+ except Exception as err:
56
+ raise ArchiveError(f"Unable to read {filename}.") from err
57
+
58
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None:
59
+ filenames = self.list_filenames()
60
+ if filename in filenames and not override:
61
+ raise ArchiveError(f"Unable to write {filename} as it already exists.")
62
+ if filename in filenames and override:
63
+ self.remove_file(filename=filename)
64
+ try:
65
+ with PDFFile(path=self.file) as archive:
66
+ archive.writestr(filename, data)
67
+ except Exception as err:
68
+ raise ArchiveError(f"Unable to write {filename}.") from err
69
+
70
+ def remove_file(self, filename: str) -> None:
71
+ try:
72
+ with PDFFile(path=self.file) as archive:
73
+ archive.remove(filename)
74
+ except Exception as err:
75
+ raise ArchiveError(f"Unable to remove {filename}.") from err
76
+
77
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002
78
+ raise ArchiveCapabilityError("PDFArchive does not support renaming files")
79
+
80
+ @classmethod
81
+ def archive_files(cls, parent: Path, name: str, files: list[Path]) -> Path: # noqa: ARG003
82
+ raise ArchiveCapabilityError("PDFArchive does not support archiving files")
@@ -0,0 +1,55 @@
1
+ __all__ = ["RarArchive"]
2
+
3
+ from pathlib import Path
4
+ from typing import ClassVar
5
+
6
+ from natsort import humansorted, ns
7
+ from rarfile import RarFile, is_rarfile
8
+
9
+ from shortbox.archives.base import Archive
10
+ from shortbox.errors import ArchiveCapabilityError, ArchiveError
11
+
12
+
13
+ class RarArchive(Archive):
14
+ extension: ClassVar[str] = ".cbr"
15
+ can_create: ClassVar[bool] = False
16
+
17
+ @classmethod
18
+ def probe(cls, file: Path) -> bool:
19
+ return is_rarfile(file)
20
+
21
+ def extract(self, filenames: list[str], destination: Path) -> None:
22
+ try:
23
+ with RarFile(file=self.file, mode="r") as archive:
24
+ archive.extractall(path=destination, members=filenames)
25
+ except Exception as err:
26
+ raise ArchiveError(
27
+ f"Unable to extract files from {self.file.name} to {destination}."
28
+ ) from err
29
+
30
+ def list_filenames(self) -> list[str]:
31
+ try:
32
+ with RarFile(file=self.file, mode="r") as archive:
33
+ return humansorted(archive.namelist(), alg=ns.G | ns.P)
34
+ except Exception as err:
35
+ raise ArchiveError(f"Unable to list files from {self.file.name}.") from err
36
+
37
+ def read_file(self, filename: str) -> bytes:
38
+ try:
39
+ with RarFile(file=self.file, mode="r") as archive:
40
+ return archive.read(filename)
41
+ except Exception as err:
42
+ raise ArchiveError(f"Unable to read {filename}.") from err
43
+
44
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None: # noqa: ARG002
45
+ raise ArchiveCapabilityError("RarArchive does not support writing files")
46
+
47
+ def remove_file(self, filename: str) -> None: # noqa: ARG002
48
+ raise ArchiveCapabilityError("RarArchive does not support removing files")
49
+
50
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002
51
+ raise ArchiveCapabilityError("RarArchive does not support renaming files")
52
+
53
+ @classmethod
54
+ def archive_files(cls, parent: Path, name: str, files: list[Path]) -> Path: # noqa: ARG003
55
+ raise ArchiveCapabilityError("RarArchive does not support archiving files")
@@ -0,0 +1,70 @@
1
+ __all__ = ["SevenZipArchive"]
2
+
3
+ from pathlib import Path
4
+ from sys import maxsize
5
+ from typing import ClassVar
6
+
7
+ from natsort import humansorted, ns
8
+ from py7zr import SevenZipFile, is_7zfile
9
+ from py7zr.io import BytesIOFactory
10
+
11
+ from shortbox.archives.base import Archive
12
+ from shortbox.errors import ArchiveCapabilityError, ArchiveError
13
+
14
+
15
+ class SevenZipArchive(Archive):
16
+ extension: ClassVar[str] = ".cb7"
17
+ can_create: ClassVar[bool] = True
18
+
19
+ @classmethod
20
+ def probe(cls, file: Path) -> bool:
21
+ return is_7zfile(file)
22
+
23
+ def extract(self, filenames: list[str], destination: Path) -> None:
24
+ try:
25
+ with SevenZipFile(file=self.file, mode="r") as archive:
26
+ archive.extract(path=destination, targets=filenames)
27
+ except Exception as err:
28
+ raise ArchiveError(
29
+ f"Unable to extract files from {self.file.name} to {destination}."
30
+ ) from err
31
+
32
+ def list_filenames(self) -> list[str]:
33
+ try:
34
+ with SevenZipFile(file=self.file, mode="r") as archive:
35
+ return humansorted(archive.getnames(), alg=ns.G | ns.P)
36
+ except Exception as err:
37
+ raise ArchiveError(f"Unable to list files from {self.file.name}.") from err
38
+
39
+ def read_file(self, filename: str) -> bytes:
40
+ try:
41
+ with SevenZipFile(file=self.file, mode="r") as archive:
42
+ factory = BytesIOFactory(maxsize)
43
+ archive.extract(targets=[filename], factory=factory)
44
+ if file_obj := factory.products.get(filename):
45
+ return file_obj.read()
46
+ raise ArchiveError(f"Unable to read {filename} in {self.file.name}") # noqa: TRY301
47
+ except ArchiveError:
48
+ raise
49
+ except Exception as err:
50
+ raise ArchiveError(f"Unable to read {filename} in {self.file.name}.") from err
51
+
52
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None: # noqa: ARG002
53
+ raise ArchiveCapabilityError("7zArchive does not support writing files")
54
+
55
+ def remove_file(self, filename: str) -> None: # noqa: ARG002
56
+ raise ArchiveCapabilityError("7zArchive does not support removing files")
57
+
58
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002
59
+ raise ArchiveCapabilityError("7zArchive does not support renaming files")
60
+
61
+ @classmethod
62
+ def archive_files(cls, parent: Path, name: str, files: list[Path]) -> Path:
63
+ output_file = parent / (name + cls.extension)
64
+ try:
65
+ with SevenZipFile(output_file, "w") as archive:
66
+ for file in files:
67
+ archive.write(file, arcname=file.name)
68
+ return output_file
69
+ except Exception as err:
70
+ raise ArchiveError(f"Unable to archive files to {output_file.name}") from err
@@ -0,0 +1,60 @@
1
+ __all__ = ["TarArchive"]
2
+
3
+ import tarfile
4
+ from pathlib import Path
5
+ from tarfile import is_tarfile
6
+ from typing import ClassVar
7
+
8
+ from natsort import humansorted, ns
9
+
10
+ from shortbox.archives.base import Archive
11
+ from shortbox.errors import ArchiveCapabilityError, ArchiveError
12
+
13
+
14
+ class TarArchive(Archive):
15
+ extension: ClassVar[str] = ".cbt"
16
+ can_create: ClassVar[bool] = True
17
+
18
+ @classmethod
19
+ def probe(cls, file: Path) -> bool:
20
+ return is_tarfile(file)
21
+
22
+ def extract(self, filenames: list[str], destination: Path) -> None:
23
+ try:
24
+ with tarfile.open(name=self.file, mode="r") as archive:
25
+ members = [archive.getmember(name) for name in filenames]
26
+ archive.extractall(path=destination, members=members, filter="data")
27
+ except Exception as err:
28
+ raise ArchiveError(
29
+ f"Unable to extract files from {self.file.name} to {destination}."
30
+ ) from err
31
+
32
+ def list_filenames(self) -> list[str]:
33
+ try:
34
+ with tarfile.open(name=self.file, mode="r") as archive:
35
+ return humansorted(archive.getnames(), alg=ns.G | ns.P)
36
+ except Exception as err:
37
+ raise ArchiveError(f"Unable to list files from {self.file.name}.") from err
38
+
39
+ def read_file(self, filename: str) -> bytes: # noqa: ARG002
40
+ raise ArchiveCapabilityError("TarArchive does not support reading files")
41
+
42
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None: # noqa: ARG002
43
+ raise ArchiveCapabilityError("TarArchive does not support writing files")
44
+
45
+ def remove_file(self, filename: str) -> None: # noqa: ARG002
46
+ raise ArchiveCapabilityError("TarArchive does not support removing files")
47
+
48
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002
49
+ raise ArchiveCapabilityError("TarArchive does not support renaming files")
50
+
51
+ @classmethod
52
+ def archive_files(cls, parent: Path, name: str, files: list[Path]) -> Path:
53
+ output_file = parent / (name + cls.extension)
54
+ try:
55
+ with tarfile.open(name=output_file, mode="w") as archive:
56
+ for file in files:
57
+ archive.add(file, arcname=file.name)
58
+ return output_file
59
+ except Exception as err:
60
+ raise ArchiveError(f"Unable to archive files to {output_file.name}") from err
@@ -0,0 +1,97 @@
1
+ __all__ = ["ZipArchive"]
2
+
3
+ from pathlib import Path
4
+ from typing import ClassVar
5
+
6
+ from natsort import humansorted, ns
7
+ from zipremove import ZIP_DEFLATED, ZipFile, is_zipfile
8
+
9
+ from shortbox.archives.base import Archive
10
+ from shortbox.errors import ArchiveError, MissingArchiveMemberError
11
+
12
+
13
+ class ZipArchive(Archive):
14
+ extension: ClassVar[str] = ".cbz"
15
+ can_create: ClassVar[bool] = True
16
+
17
+ @classmethod
18
+ def probe(cls, file: Path) -> bool:
19
+ return is_zipfile(file)
20
+
21
+ def extract(self, filenames: list[str], destination: Path) -> None:
22
+ print(f"Extracting {self.file.name} archive") # noqa: T201
23
+ try:
24
+ with ZipFile(file=self.file, mode="r") as archive:
25
+ archive.extractall(path=destination, members=filenames)
26
+ except Exception as err:
27
+ raise ArchiveError(
28
+ f"Unable to extract files from {self.file.name} to {destination}."
29
+ ) from err
30
+
31
+ def list_filenames(self) -> list[str]:
32
+ try:
33
+ with ZipFile(file=self.file, mode="r") as archive:
34
+ return humansorted(archive.namelist(), alg=ns.G | ns.P)
35
+ except Exception as err:
36
+ raise ArchiveError(f"Unable to list files from {self.file.name}.") from err
37
+
38
+ def read_file(self, filename: str) -> bytes:
39
+ try:
40
+ with ZipFile(file=self.file, mode="r") as archive, archive.open(filename) as zip_file:
41
+ return zip_file.read()
42
+ except Exception as err:
43
+ raise ArchiveError(f"Unable to read {filename}.") from err
44
+
45
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None:
46
+ try:
47
+ with ZipFile(file=self.file, mode="a") as archive:
48
+ if filename in archive.namelist():
49
+ if not override:
50
+ raise ArchiveError(f"Unable to write {filename} as it already exists.") # noqa: TRY301
51
+ removed = archive.remove(filename) # ty: ignore[unresolved-attribute]
52
+ archive.repack([removed]) # ty: ignore[unresolved-attribute]
53
+ archive.writestr(filename, data)
54
+ except ArchiveError:
55
+ raise
56
+ except Exception as err:
57
+ raise ArchiveError(f"Unable to write {filename}.") from err
58
+
59
+ def remove_file(self, filename: str) -> None:
60
+ if filename not in self.list_filenames():
61
+ raise MissingArchiveMemberError(f"Unable to remove {filename} as it does not exist.")
62
+ try:
63
+ with ZipFile(file=self.file, mode="a") as archive:
64
+ removed = archive.remove(filename) # ty: ignore[unresolved-attribute]
65
+ archive.repack([removed]) # ty: ignore[unresolved-attribute]
66
+ except Exception as err:
67
+ raise ArchiveError(f"Unable to delete {filename}.") from err
68
+
69
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None:
70
+ if filename not in self.list_filenames():
71
+ raise MissingArchiveMemberError(f"Unable to rename {filename} as it does not exist.")
72
+ try:
73
+ removed = []
74
+ with ZipFile(file=self.file, mode="a") as archive:
75
+ if new_name in archive.namelist():
76
+ if not override:
77
+ raise ArchiveError( # noqa: TRY301
78
+ f"Unable to rename {filename} as {new_name} already exists."
79
+ )
80
+ removed.append(archive.remove(new_name)) # ty: ignore[unresolved-attribute]
81
+ removed.append(archive.remove(archive.copy(filename, new_name))) # ty: ignore[unresolved-attribute]
82
+ archive.repack(removed) # ty: ignore[unresolved-attribute]
83
+ except ArchiveError:
84
+ raise
85
+ except Exception as err:
86
+ raise ArchiveError(f"Unable to rename {filename} to {new_name}.") from err
87
+
88
+ @classmethod
89
+ def archive_files(cls, parent: Path, name: str, files: list[Path]) -> Path:
90
+ output_file = parent / (name + cls.extension)
91
+ try:
92
+ with ZipFile(file=output_file, mode="w", compression=ZIP_DEFLATED) as archive:
93
+ for file in files:
94
+ archive.write(file, arcname=file.name)
95
+ return output_file
96
+ except Exception as err:
97
+ raise ArchiveError(f"Unable to archive files to {output_file.name}.") from err
shortbox/comic.py ADDED
@@ -0,0 +1,216 @@
1
+ __all__ = ["Comic"]
2
+
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+ from typing import TypeVar
8
+
9
+ from msgspec_xml import decode, encode
10
+ from natsort import humansorted, ns
11
+
12
+ from shortbox.archives.base import Archive
13
+ from shortbox.errors import (
14
+ ArchiveCapabilityError,
15
+ ArchiveError,
16
+ MissingArchiveMemberError,
17
+ UnsupportedMetadataError,
18
+ )
19
+ from shortbox.metadata.base import Metadata
20
+ from shortbox.registries import archive_registry, metadata_registry
21
+
22
+ MetadataT = TypeVar("MetadataT", bound=Metadata)
23
+
24
+
25
+ class Comic:
26
+ def __init__(self, archive: Archive, tmpdir: Path):
27
+ self._archive = archive
28
+ self._tmpdir = tmpdir
29
+ self._extracted: bool = False
30
+ self._dirty: bool = False
31
+ self._metadata_cache: dict[type[Metadata], Metadata] = {}
32
+
33
+ @classmethod
34
+ @contextmanager
35
+ def open(cls, file: Path) -> Generator["Comic"]:
36
+ archive = archive_registry.find(file=file)
37
+ with TemporaryDirectory() as temp:
38
+ instance = cls(archive=archive, tmpdir=Path(temp))
39
+ try:
40
+ yield instance
41
+ if instance._extracted and instance._dirty:
42
+ instance._archive.file = instance._archive.archive_files(
43
+ parent=instance.file.parent,
44
+ name=instance.file.stem,
45
+ files=instance._iter_extracted_files(),
46
+ )
47
+ finally:
48
+ instance._extracted = False
49
+ instance._dirty = False
50
+
51
+ @property
52
+ def file(self) -> Path:
53
+ return self._archive.file
54
+
55
+ def _iter_extracted_files(self) -> list[Path]:
56
+ return humansorted([p for p in self._tmpdir.rglob("*") if p.is_file()], alg=ns.G | ns.P)
57
+
58
+ def list_filenames(self) -> list[str]:
59
+ if self._extracted:
60
+ return [str(x.relative_to(self._tmpdir)) for x in self._iter_extracted_files()]
61
+ return self._archive.list_filenames()
62
+
63
+ def _ensure_extracted(self) -> None:
64
+ if not self._extracted:
65
+ self._archive.extract(
66
+ filenames=self._archive.list_filenames(), destination=self._tmpdir
67
+ )
68
+ self._extracted = True
69
+
70
+ def read_file(self, filename: str) -> bytes:
71
+ try:
72
+ return self._archive.read_file(filename=filename)
73
+ except ArchiveCapabilityError:
74
+ self._ensure_extracted()
75
+ tmp = self._tmpdir / filename
76
+ return tmp.read_bytes()
77
+
78
+ def write_file(self, filename: str, data: bytes, override: bool = False) -> None:
79
+ self._invalidate_cache(filename=filename)
80
+ try:
81
+ return self._archive.write_file(filename=filename, data=data, override=override)
82
+ except ArchiveCapabilityError as err:
83
+ if not self._archive.can_create:
84
+ raise
85
+ self._ensure_extracted()
86
+ tmp = self._tmpdir / filename
87
+ if tmp.exists() and not override:
88
+ raise ArchiveError(f"Unable to write {filename} as it already exists.") from err
89
+ tmp.write_bytes(data)
90
+ self._dirty = True
91
+ return None
92
+
93
+ def remove_file(self, filename: str) -> None:
94
+ self._invalidate_cache(filename=filename)
95
+ try:
96
+ return self._archive.remove_file(filename=filename)
97
+ except ArchiveCapabilityError as err:
98
+ if not self._archive.can_create:
99
+ raise
100
+ self._ensure_extracted()
101
+ tmp = self._tmpdir / filename
102
+ if not tmp.exists():
103
+ raise MissingArchiveMemberError(
104
+ f"Unable to remove {filename} as it does not exist."
105
+ ) from err
106
+ tmp.unlink()
107
+ self._dirty = True
108
+ return None
109
+
110
+ def rename_file(self, filename: str, new_name: str, override: bool = False) -> None:
111
+ self._invalidate_cache(filename=filename)
112
+ try:
113
+ return self._archive.rename_file(
114
+ filename=filename, new_name=new_name, override=override
115
+ )
116
+ except ArchiveCapabilityError as err:
117
+ if not self._archive.can_create:
118
+ raise
119
+ self._ensure_extracted()
120
+ tmp = self._tmpdir / filename
121
+ if not tmp.exists():
122
+ raise MissingArchiveMemberError(
123
+ f"Unable to rename {filename} as it does not exist."
124
+ ) from err
125
+ new_tmp = self._tmpdir / new_name
126
+ if new_tmp.exists() and not override:
127
+ raise ArchiveError(
128
+ f"Unable to rename {filename} as {new_name} already exists."
129
+ ) from err
130
+ tmp.rename(new_tmp)
131
+ self._dirty = True
132
+ return None
133
+
134
+ def _invalidate_cache(self, filename: str) -> None:
135
+ try:
136
+ metadata_cls = metadata_registry.find(filename=filename)
137
+ except UnsupportedMetadataError:
138
+ return
139
+ self._metadata_cache.pop(metadata_cls, None)
140
+
141
+ def _iter_metadata_files(self) -> Generator[tuple[str, type[Metadata]]]:
142
+ for filename in self.list_filenames():
143
+ try:
144
+ metadata_cls = metadata_registry.find(filename=filename)
145
+ except UnsupportedMetadataError:
146
+ continue
147
+ yield filename, metadata_cls
148
+
149
+ def get_metadata(self, metadata_type: type[MetadataT]) -> MetadataT | None:
150
+ cached = self._metadata_cache.get(metadata_type)
151
+ if isinstance(cached, metadata_type):
152
+ return cached
153
+ for filename, metadata_cls in self._iter_metadata_files():
154
+ if metadata_cls is not metadata_type:
155
+ continue
156
+ instance = decode(self.read_file(filename=filename), type_=metadata_type)
157
+ self._metadata_cache[metadata_type] = instance
158
+ return instance
159
+ return None
160
+
161
+ def set_metadata(self, metadata: Metadata) -> None:
162
+ metadata_type = type(metadata)
163
+ filename = metadata.filename
164
+ data = encode(metadata, indent=4, xml_declaration=True)
165
+ self.write_file(filename=filename, data=data, override=True)
166
+ self._metadata_cache[metadata_type] = metadata
167
+
168
+ def remove_metadata(self, metadata_type: type[Metadata]) -> None:
169
+ filename = metadata_type.filename
170
+ self.remove_file(filename=filename)
171
+
172
+ @property
173
+ def metadata(self) -> dict[type[Metadata], Metadata]:
174
+ for filename, metadata_cls in self._iter_metadata_files():
175
+ if metadata_cls not in self._metadata_cache:
176
+ self._metadata_cache[metadata_cls] = decode(
177
+ self.read_file(filename=filename), type_=metadata_cls
178
+ )
179
+ return dict(self._metadata_cache)
180
+
181
+ def convert(
182
+ self,
183
+ archive_type: type[Archive],
184
+ delete_original: bool = False,
185
+ raise_on_existing: bool = True,
186
+ ) -> Path:
187
+ if not archive_type.can_create:
188
+ raise ArchiveCapabilityError(
189
+ f"{archive_type.__name__} does not support archiving files"
190
+ )
191
+ target_path = self.file.with_suffix(archive_type.extension)
192
+ if target_path.resolve() == self.file.resolve():
193
+ if raise_on_existing:
194
+ raise ArchiveError(f"{target_path!r} already exists")
195
+ return self.file
196
+ self._ensure_extracted()
197
+ files = self._iter_extracted_files()
198
+ new_path = archive_type.archive_files(
199
+ parent=target_path.parent, name=target_path.stem, files=files
200
+ )
201
+ original_file = self.file
202
+ self._archive = archive_type(file=new_path)
203
+ self._extracted = False
204
+ self._dirty = False
205
+ self._metadata_cache.clear()
206
+ if delete_original:
207
+ original_file.unlink()
208
+ return new_path
209
+
210
+ def get_cover(self) -> bytes:
211
+ for filename in self.list_filenames():
212
+ try:
213
+ metadata_registry.find(filename=filename)
214
+ except UnsupportedMetadataError: # noqa: PERF203
215
+ return self.read_file(filename=filename)
216
+ raise MissingArchiveMemberError(f"{self.file.name!r} contains no files to use as a cover.")