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 +4 -0
- shortbox/archives/__init__.py +27 -0
- shortbox/archives/base.py +39 -0
- shortbox/archives/pdf.py +82 -0
- shortbox/archives/rar.py +55 -0
- shortbox/archives/sevenzip.py +70 -0
- shortbox/archives/tar.py +60 -0
- shortbox/archives/zip.py +97 -0
- shortbox/comic.py +216 -0
- shortbox/errors.py +34 -0
- shortbox/metadata/__init__.py +9 -0
- shortbox/metadata/base.py +105 -0
- shortbox/metadata/comic_info.py +252 -0
- shortbox/metadata/metron_info.py +337 -0
- shortbox/registries.py +48 -0
- shortbox/utils.py +18 -0
- shortbox-0.4.0.dist-info/METADATA +141 -0
- shortbox-0.4.0.dist-info/RECORD +19 -0
- shortbox-0.4.0.dist-info/WHEEL +4 -0
shortbox/__init__.py
ADDED
|
@@ -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: ...
|
shortbox/archives/pdf.py
ADDED
|
@@ -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")
|
shortbox/archives/rar.py
ADDED
|
@@ -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
|
shortbox/archives/tar.py
ADDED
|
@@ -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
|
shortbox/archives/zip.py
ADDED
|
@@ -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.")
|