diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42492bc..80ba77f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.11 + rev: v0.14.13 hooks: - id: ruff-format - id: ruff-check diff --git a/README.md b/README.md index d179073..a0bb211 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,6 @@ File will be created on first run. ```toml [output] folder = "~/.local/share/perdoo" -format = "cbz" [output.comic_info] create = true @@ -184,10 +183,6 @@ password = "" The folder where the output files will be stored. Defaults to `~/.local/share/perdoo`. -- `output.format` - The output file format for the comic archives. - Defaults to `cbz`. - - `output.comic_info.create` Whether to create a ComicInfo.xml file in the output archive. Defaults to `true`. diff --git a/perdoo/__main__.py b/perdoo/__main__.py index 9afd60c..91c97e8 100644 --- a/perdoo/__main__.py +++ b/perdoo/__main__.py @@ -11,11 +11,12 @@ from perdoo import __version__, get_cache_root, setup_logging from perdoo.cli import archive_app, settings_app -from perdoo.comic import SUPPORTED_IMAGE_EXTENSIONS, Comic, ComicArchiveError, ComicMetadataError +from perdoo.comic import Comic +from perdoo.comic.errors import ComicArchiveError, ComicMetadataError +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata.comic_info import Page +from perdoo.comic.metadata.metron_info import Id, InformationSource from perdoo.console import CONSOLE -from perdoo.metadata import ComicInfo, MetronInfo -from perdoo.metadata.comic_info import Page -from perdoo.metadata.metron_info import Id, InformationSource from perdoo.services import BaseService, Comicvine, Metron from perdoo.settings import Service, Services, Settings from perdoo.utils import ( @@ -74,7 +75,7 @@ def _load_comics(target: Path) -> list[Comic]: files = list_files(target) if target.is_dir() else [target] for file in files: try: - comics.append(Comic(file=file)) + comics.append(Comic(filepath=file)) except (ComicArchiveError, ComicMetadataError) as err: # noqa: PERF203 LOGGER.error("Failed to load '%s' as a Comic: %s", file, err) return comics @@ -132,13 +133,12 @@ def get_search_details( def load_page_info(entry: Comic, comic_info: ComicInfo) -> list[Page]: from PIL import Image # noqa: PLC0415 - from perdoo.metadata.comic_info import PageType # noqa: PLC0415 + from perdoo.comic import IMAGE_EXTENSIONS # noqa: PLC0415 + from perdoo.comic.metadata.comic_info import PageType # noqa: PLC0415 pages = set() image_files = [ - x - for x in entry.archive.get_filename_list() - if Path(x).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS + x for x in entry.archive.list_filenames() if Path(x).suffix.lower() in IMAGE_EXTENSIONS ] for idx, file in enumerate(image_files): page = next((x for x in comic_info.pages if x.image == idx), None) @@ -169,7 +169,6 @@ def sync_metadata( ) -> tuple[MetronInfo | None, ComicInfo | None]: for service_name in settings.services.order: if service := services.get(service_name): - LOGGER.info("Searching %s for matching issue", type(service).__name__) metron_info, comic_info = service.fetch(search=search) if metron_info or comic_info: return metron_info, comic_info @@ -252,21 +251,19 @@ def run( comics = _load_comics(target=target) for index, entry in enumerate(comics): CONSOLE.rule( - f"[{index + 1}/{len(comics)}] Importing {entry.path.name}", + f"[{index + 1}/{len(comics)}] Importing {entry.filepath.name}", align="left", style="subtitle", ) if not skip_convert: - with CONSOLE.status( - f"Converting to '{settings.output.format}'", spinner="simpleDotsScrolling" - ): - entry.convert(extension=settings.output.format) + with CONSOLE.status("Converting to '.cbz'", spinner="simpleDotsScrolling"): + entry.convert_to(extension="cbz") metadata: tuple[MetronInfo | None, ComicInfo | None] = (entry.metron_info, entry.comic_info) if sync != SyncOption.SKIP: - search = get_search_details(metadata=metadata, filename=entry.path.stem) - search.filename = entry.path.stem + search = get_search_details(metadata=metadata, filename=entry.filepath.stem) + search.filename = entry.filepath.stem last_modified = date(1900, 1, 1) if sync == SyncOption.OUTDATED: metron_info, _ = metadata diff --git a/perdoo/cli/archive.py b/perdoo/cli/archive.py index e21932e..61cae3e 100644 --- a/perdoo/cli/archive.py +++ b/perdoo/cli/archive.py @@ -24,8 +24,8 @@ def view( bool, Option("--hide-metron-info", help="Don't show the MetronInfo details.") ] = False, ) -> None: - comic = Comic(file=target) - CONSOLE.print(f"Archive format: '{comic.path.suffix}'") + comic = Comic(filepath=target) + CONSOLE.print(f"Archive format: '{comic.filepath.suffix}'") if not hide_metron_info: if not comic.metron_info: CONSOLE.print("No MetronInfo found") diff --git a/perdoo/comic.py b/perdoo/comic.py deleted file mode 100644 index 3405d0e..0000000 --- a/perdoo/comic.py +++ /dev/null @@ -1,198 +0,0 @@ -__all__ = [ - "SUPPORTED_IMAGE_EXTENSIONS", - "Comic", - "ComicArchiveError", - "ComicMetadataError", - "MetadataFormat", -] - -import logging -import shutil -from pathlib import Path -from typing import Final, Literal, TypeVar - -from darkseid.archivers import PY7ZR_AVAILABLE, Archiver, ArchiverFactory, TarArchiver, ZipArchiver -from darkseid.comic import ( - COMIC_RACK_FILENAME, - METRON_INFO_FILENAME, - SUPPORTED_IMAGE_EXTENSIONS, - ComicArchiveError, - ComicMetadataError, - MetadataFormat, -) -from natsort import humansorted, ns - -from perdoo.metadata import ComicInfo, MetronInfo -from perdoo.settings import Naming - -LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=ComicInfo | MetronInfo) - - -class Comic: - _ZIP_EXTENSION: Final[str] = ".cbz" - _RAR_EXTENSION: Final[str] = ".cbr" - _TAR_EXTENSION: Final[str] = ".cbt" - _7Z_EXTENSION: Final[str] = ".cb7" - - def __init__(self, file: Path) -> None: - self._archiver: Archiver | None = None - self._comic_info: ComicInfo | None = None - self._metron_info: MetronInfo | None = None - - self._setup_archive(file=file) - self.read_metadata(metadata_format=MetadataFormat.COMIC_INFO) - self.read_metadata(metadata_format=MetadataFormat.METRON_INFO) - - @property - def archive(self) -> Archiver: - return self._archiver - - @property - def path(self) -> Path: - return self.archive.path - - @property - def comic_info(self) -> ComicInfo | None: - return self._comic_info - - @property - def metron_info(self) -> MetronInfo | None: - return self._metron_info - - def is_cbz(self) -> bool: - return self.path.suffix.lower() == self._ZIP_EXTENSION - - def is_cbr(self) -> bool: - return self.path.suffix.lower() == self._RAR_EXTENSION - - def is_cbt(self) -> bool: - return self.path.suffix.lower() == self._TAR_EXTENSION - - def is_cb7(self) -> bool: - return self.path.suffix.lower() == self._7Z_EXTENSION - - def _setup_archive(self, file: Path) -> None: - if PY7ZR_AVAILABLE: - from darkseid.archivers import SevenZipArchiver # noqa: PLC0415 - - ArchiverFactory.register_archiver(self._7Z_EXTENSION, SevenZipArchiver) - try: - self._archiver: Archiver = ArchiverFactory.create_archiver(path=file) - except Exception as err: - raise ComicArchiveError(f"Failed to create archiver for {file}: {err}") from err - - def _read_metadata_file(self, filename: str, metadata_class: type[T]) -> T | None: - if self.archive.exists(archive_file=filename): - return metadata_class.from_bytes(content=self.archive.read_file(archive_file=filename)) - LOGGER.info( - "'%s' does not contain '%s', skipping %s metadata", - self.archive.path.name, - filename, - metadata_class.__name__, - ) - return None - - def read_metadata(self, metadata_format: MetadataFormat) -> None: - if metadata_format == MetadataFormat.COMIC_INFO: - self._comic_info = self._read_metadata_file(COMIC_RACK_FILENAME, ComicInfo) - elif metadata_format == MetadataFormat.METRON_INFO: - self._metron_info = self._read_metadata_file(METRON_INFO_FILENAME, MetronInfo) - else: - raise ComicMetadataError(f"Unsupported metadata format: {metadata_format}") - - def convert(self, extension: Literal["cbt", "cbz"]) -> None: - check, archiver = {"cbt": (self.is_cbt, TarArchiver), "cbz": (self.is_cbz, ZipArchiver)}[ - extension - ] - if check(): - return - output_file = self.path.with_suffix(f".{extension}") - with self.archive as source, archiver(path=output_file) as destination: - LOGGER.debug("Converting '%s' to '%s'", source.path.name, destination.path.name) - if destination.copy_from_archive(other_archive=source): - self._archiver = destination - source.path.unlink() - - def clean_archive(self) -> None: - with self.archive as source: - for filename in source.get_filename_list(): - filepath = Path(filename) - if ( - filepath.name not in {COMIC_RACK_FILENAME, METRON_INFO_FILENAME} - and filepath.suffix.lower() not in SUPPORTED_IMAGE_EXTENSIONS - ): - source.remove_files(filename_list=[filename]) - LOGGER.info("Removed '%s' from '%s'", filename, source.path.name) - - def write_metadata(self, metadata: ComicInfo | MetronInfo | None) -> None: - metadata_config = { - ComicInfo: (COMIC_RACK_FILENAME, MetadataFormat.COMIC_INFO), - MetronInfo: (METRON_INFO_FILENAME, MetadataFormat.METRON_INFO), - } - config = metadata_config.get(type(metadata)) - if not config: - raise ComicMetadataError(f"Unsupported metadata type: {type(metadata)}") - - filename, format_type = config - with self.archive as source: - source.write_file(archive_file=filename, data=metadata.to_bytes().decode()) - self.read_metadata(metadata_format=format_type) - LOGGER.info("Wrote %s to '%s'", type(metadata).__name__, source.path.name) - - def _get_filepath_from_metadata(self, naming: Naming) -> str | None: - if self.metron_info: - return self.metron_info.get_filename(settings=naming) - if self.comic_info: - return self.comic_info.get_filename(settings=naming) - return None - - def _rename_images(self, base_name: str) -> None: - with self.archive as source: - files = humansorted( - [ - x - for x in source.get_filename_list() - if Path(x).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS - ], - alg=ns.NA | ns.G | ns.P, - ) - pad_count = len(str(len(files))) if files else 1 - for idx, filename in enumerate(files): - img_file = Path(filename) - new_file = img_file.with_stem(f"{base_name}_{str(idx).zfill(pad_count)}") - if new_file.stem != img_file.stem: - LOGGER.info("Renaming '%s' to '%s'", img_file.name, new_file.name) - file_contents = source.read_file(archive_file=filename) - source.remove_files(filename_list=[filename]) - source.write_file(archive_file=new_file.name, data=file_contents) - - def rename(self, naming: Naming, output_folder: Path) -> None: - new_filepath = self._get_filepath_from_metadata(naming=naming) - if new_filepath is None: - LOGGER.warning("Not enough information to rename '%s', skipping", self.path.stem) - return - new_filepath = new_filepath.lstrip("/") - - output = output_folder / f"{new_filepath}.cbz" - if output.relative_to(output_folder) == self.path.resolve().relative_to(output_folder): - return - if output.exists(): - LOGGER.warning("'%s' already exists, skipping", output.relative_to(output_folder)) - return - output.parent.mkdir(parents=True, exist_ok=True) - - LOGGER.info( - "Renaming '%s' to '%s'", self.path.name, output.relative_to(output_folder.parent) - ) - shutil.move(self.path, output) - self.archive._path = output # noqa: SLF001 - - new_filename = self.path.stem - if all( - x.startswith(new_filename) - for x in self.archive.get_filename_list() - if Path(x).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS - ): - return - self._rename_images(base_name=new_filename) diff --git a/perdoo/comic/__init__.py b/perdoo/comic/__init__.py new file mode 100644 index 0000000..bd959e1 --- /dev/null +++ b/perdoo/comic/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["IMAGE_EXTENSIONS", "Comic"] + +from perdoo.comic.comic import IMAGE_EXTENSIONS, Comic diff --git a/perdoo/comic/archive/__init__.py b/perdoo/comic/archive/__init__.py new file mode 100644 index 0000000..53f7b87 --- /dev/null +++ b/perdoo/comic/archive/__init__.py @@ -0,0 +1,7 @@ +__all__ = ["Archive", "CB7Archive", "CBRArchive", "CBTArchive", "CBZArchive"] + +from perdoo.comic.archive._base import Archive +from perdoo.comic.archive.rar import CBRArchive +from perdoo.comic.archive.sevenzip import CB7Archive +from perdoo.comic.archive.tar import CBTArchive +from perdoo.comic.archive.zip import CBZArchive diff --git a/perdoo/comic/archive/_base.py b/perdoo/comic/archive/_base.py new file mode 100644 index 0000000..d74176c --- /dev/null +++ b/perdoo/comic/archive/_base.py @@ -0,0 +1,67 @@ +__all__ = ["Archive"] + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import ClassVar + +from perdoo.comic.errors import ComicArchiveError + +try: + from typing import Self # Python >= 3.11 +except ImportError: + from typing_extensions import Self # Python < 3.11 + + +class Archive(ABC): + _registry: ClassVar[list[type["Archive"]]] = [] + EXTENSION: ClassVar[str] = "" + + def __init__(self, filepath: Path) -> None: + self._filepath = filepath + + def __init_subclass__(cls, **kwargs) -> None: # noqa: ANN003 + super().__init_subclass__(**kwargs) + Archive._registry.append(cls) + + @property + def filepath(self) -> Path: + return self._filepath + + @classmethod + def load(cls, filepath: Path) -> Self: + for _cls in cls._registry: + if _cls.is_archive(filepath): + return _cls(filepath=filepath) + raise ComicArchiveError(f"Unsupported archive format: {filepath.suffix.lower()}") + + @classmethod + @abstractmethod + def is_archive(cls, path: Path) -> bool: ... + + @abstractmethod + def list_filenames(self) -> list[str]: ... + + def exists(self, filename: str) -> bool: + return filename in self.list_filenames() + + @abstractmethod + def read_file(self, filename: str) -> bytes: ... + + def write_file(self, filename: str, data: str | bytes) -> None: # noqa: ARG002 + raise ComicArchiveError(f"Unable to write {filename} to {self.filepath.name}.") + + def remove_file(self, filename: str) -> None: + raise ComicArchiveError(f"Unable to delete {filename} in {self.filepath.name}.") + + @abstractmethod + def extract_files(self, destination: Path) -> None: ... + + @classmethod + def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Self: # noqa: ARG003 + raise ComicArchiveError(f"Unable to archive files to {output_name}.") + + @classmethod + def convert_from(cls, old_archive: "Archive") -> Self: + raise ComicArchiveError( + f"Unable to convert {old_archive.filepath.name} to a {cls.EXTENSION}" + ) diff --git a/perdoo/comic/archive/rar.py b/perdoo/comic/archive/rar.py new file mode 100644 index 0000000..c7ec23b --- /dev/null +++ b/perdoo/comic/archive/rar.py @@ -0,0 +1,43 @@ +__all__ = ["CBRArchive"] + +import logging +from pathlib import Path +from typing import ClassVar + +from rarfile import RarFile, is_rarfile + +from perdoo.comic.archive._base import Archive +from perdoo.comic.errors import ComicArchiveError + +LOGGER = logging.getLogger(__name__) + + +class CBRArchive(Archive): + EXTENSION: ClassVar[str] = ".cbr" + + @classmethod + def is_archive(cls, path: Path) -> bool: + if path.suffix.lower() != cls.EXTENSION: + return False + return is_rarfile(xfile=path) + + def list_filenames(self) -> list[str]: + try: + with RarFile(file=self.filepath, mode="r") as archive: + return archive.namelist() + except Exception as err: + raise ComicArchiveError(f"Unable to list files in {self.filepath.name}") from err + + def read_file(self, filename: str) -> bytes: + try: + with RarFile(file=self.filepath, mode="r") as archive: + return archive.read(filename) + except Exception as err: + raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}.") from err + + def extract_files(self, destination: Path) -> None: + try: + with RarFile(file=self.filepath, mode="r") as archive: + archive.extractall(path=destination) + except Exception as err: + raise ComicArchiveError(f"Unable to extract files from {self.filepath.name}.") from err diff --git a/perdoo/comic/archive/sevenzip.py b/perdoo/comic/archive/sevenzip.py new file mode 100644 index 0000000..7f31c3a --- /dev/null +++ b/perdoo/comic/archive/sevenzip.py @@ -0,0 +1,60 @@ +__all__ = ["CB7Archive"] + +import logging +from pathlib import Path +from sys import maxsize +from typing import ClassVar + +try: + import py7zr + + PY7ZR_AVAILABLE = True +except ImportError: + py7zr = None + PY7ZR_AVAILABLE = False + +from perdoo.comic.archive._base import Archive +from perdoo.comic.errors import ComicArchiveError + +LOGGER = logging.getLogger(__name__) + + +class CB7Archive(Archive): + EXTENSION: ClassVar[str] = ".cb7" + + @classmethod + def is_archive(cls, path: Path) -> bool: + if not PY7ZR_AVAILABLE: + return False + if path.suffix.lower() != cls.EXTENSION: + return False + return py7zr.is_7zfile(file=path) + + def list_filenames(self) -> list[str]: + try: + with py7zr.SevenZipFile(self.filepath, "r") as archive: + return archive.namelist() + except Exception as err: + raise ComicArchiveError(f"Unable to list files in {self.filepath.name}") from err + + def read_file(self, filename: str) -> bytes: + try: + with py7zr.SevenZipFile(self.filepath, "r") as archive: + factory = py7zr.io.BytesIOFactory(maxsize) + archive.extract(targets=[filename], factory=factory) + if file_obj := factory.products.get(filename): + return file_obj.read() + raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}") # noqa: TRY301 + except ComicArchiveError: + raise + except Exception as err: + raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}") from err + + def extract_files(self, destination: Path) -> None: + try: + with py7zr.SevenZipFile(file=self.filepath, mode="r") as archive: + archive.extractall(path=destination) + except Exception as err: + raise ComicArchiveError( + f"Unable to extract all files from {self.filepath.name} to {destination}" + ) from err diff --git a/perdoo/comic/archive/tar.py b/perdoo/comic/archive/tar.py new file mode 100644 index 0000000..fa018e4 --- /dev/null +++ b/perdoo/comic/archive/tar.py @@ -0,0 +1,42 @@ +__all__ = ["CBTArchive"] + +import logging +import tarfile +from pathlib import Path +from typing import ClassVar + +from perdoo.comic.archive._base import Archive +from perdoo.comic.errors import ComicArchiveError + +LOGGER = logging.getLogger(__name__) + + +class CBTArchive(Archive): + EXTENSION: ClassVar[str] = ".cbt" + + @classmethod + def is_archive(cls, path: Path) -> bool: + if path.suffix.lower() != cls.EXTENSION: + return False + return tarfile.is_tarfile(name=path) + + def list_filenames(self) -> list[str]: + try: + with tarfile.open(name=self.filepath, mode="r") as archive: + return archive.getnames() + except Exception as err: + raise ComicArchiveError(f"Unable to list files in {self.filepath.name}") from err + + def read_file(self, filename: str) -> bytes: + try: + with tarfile.open(name=self.filepath, mode="r") as archive: + return archive.extractfile(filename).read() + except Exception as err: + raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}") from err + + def extract_files(self, destination: Path) -> None: + try: + with tarfile.open(name=self.filepath, mode="r") as archive: + archive.extractall(path=destination, filter="data") + except Exception as err: + raise ComicArchiveError(f"Unable to extract files from {self.filepath.name}.") from err diff --git a/perdoo/comic/archive/zip.py b/perdoo/comic/archive/zip.py new file mode 100644 index 0000000..b2fcfe3 --- /dev/null +++ b/perdoo/comic/archive/zip.py @@ -0,0 +1,106 @@ +__all__ = ["CBZArchive"] + +import logging +import shutil +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import ClassVar + +from zipremove import ZIP_DEFLATED, ZipFile, is_zipfile + +from perdoo.comic.archive._base import Archive +from perdoo.comic.errors import ComicArchiveError +from perdoo.utils import list_files + +try: + from typing import Self # Python >= 3.11 +except ImportError: + from typing_extensions import Self # Python < 3.11 + +LOGGER = logging.getLogger(__name__) + + +class CBZArchive(Archive): + EXTENSION: ClassVar[str] = ".cbz" + + @classmethod + def is_archive(cls, path: Path) -> bool: + if path.suffix.lower() != cls.EXTENSION: + return False + return is_zipfile(filename=path) + + def list_filenames(self) -> list[str]: + try: + with ZipFile(file=self.filepath, mode="r") as archive: + return archive.namelist() + except Exception as err: + raise ComicArchiveError(f"Unable to list files in {self.filepath.name}") from err + + def read_file(self, filename: str) -> bytes: + try: + with ( + ZipFile(file=self.filepath, mode="r") as archive, + archive.open(filename) as zip_file, + ): + return zip_file.read() + except Exception as err: + raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}") from err + + def write_file(self, filename: str, data: str | bytes) -> None: + if isinstance(data, str): + data = data.encode("UTF-8") + try: + with ZipFile(file=self.filepath, mode="a") as archive: + if filename in archive.namelist(): + removed = archive.remove(filename) + archive.repack([removed]) + archive.writestr(filename, data) + except Exception as err: + raise ComicArchiveError(f"Unable to write {filename} to {self.filepath.name}") from err + + def remove_file(self, filename: str) -> None: + if filename not in self.list_filenames(): + return + try: + with ZipFile(file=self.filepath, mode="a") as archive: + removed = archive.remove(filename) + archive.repack([removed]) + except Exception as err: + raise ComicArchiveError( + f"Unable to delete {filename} from {self.filepath.name}" + ) from err + + def extract_files(self, destination: Path) -> None: + try: + with ZipFile(file=self.filepath, mode="r") as archive: + archive.extractall(path=destination) + except Exception as err: + raise ComicArchiveError( + f"Unable to extract all files from {self.filepath.name} to {destination}" + ) from err + + @classmethod + def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Self: + output_file = src.parent / f"{output_name}.cbz" + try: + with ZipFile(file=output_file, mode="w", compression=ZIP_DEFLATED) as archive: + for file in files: + archive.write(file, arcname=file.name) + return cls(filepath=output_file) + except Exception as err: + raise ComicArchiveError(f"Unable to archive files to {output_file.name}") from err + + @classmethod + def convert_from(cls, old_archive: Archive) -> Self: + with TemporaryDirectory(prefix=f"{old_archive.filepath.stem}_") as temp_str: + temp_folder = Path(temp_str) + old_archive.extract_files(destination=temp_folder) + new_archive = cls.archive_files( + src=temp_folder, + output_name=old_archive.filepath.stem, + files=list_files(temp_folder), + ) + new_file = old_archive.filepath.with_suffix(cls.EXTENSION) + old_archive.filepath.unlink(missing_ok=True) + shutil.move(new_archive.filepath, new_file) + return cls(filepath=new_file) diff --git a/perdoo/comic/comic.py b/perdoo/comic/comic.py new file mode 100644 index 0000000..516cb06 --- /dev/null +++ b/perdoo/comic/comic.py @@ -0,0 +1,118 @@ +__all__ = ["IMAGE_EXTENSIONS", "Comic"] + +import logging +import shutil +from pathlib import Path +from typing import Final, Literal + +from natsort import humansorted, ns + +from perdoo.comic.archive import Archive, CBZArchive +from perdoo.comic.metadata import ComicInfo, Metadata, MetronInfo +from perdoo.settings import Naming + +LOGGER = logging.getLogger(__name__) +METADATA_FILENAMES: Final[frozenset[str]] = frozenset(["ComicInfo.xml", "MetronInfo.xml"]) +IMAGE_EXTENSIONS: Final[frozenset[str]] = frozenset([".png", ".jpg", ".jpeg", ".webp", ".jxl"]) + + +class Comic: + def __init__(self, filepath: Path): + self._archive: Archive = Archive.load(filepath=filepath) + self._metadata: dict[str, Metadata | None] = {} + self._load_metadata() + + @property + def archive(self) -> Archive: + return self._archive + + @property + def filepath(self) -> Path: + return self.archive.filepath + + @property + def comic_info(self) -> ComicInfo | None: + return self._metadata.get("ComicInfo") + + @property + def metron_info(self) -> MetronInfo | None: + return self._metadata.get("MetronInfo") + + def _load_metadata(self) -> None: + if self.archive.exists(filename="ComicInfo.xml"): + self._metadata["ComicInfo"] = ComicInfo.from_bytes( + content=self.archive.read_file(filename="ComicInfo.xml") + ) + if self.archive.exists(filename="MetronInfo.xml"): + self._metadata["MetronInfo"] = MetronInfo.from_bytes( + content=self.archive.read_file(filename="MetronInfo.xml") + ) + + def convert_to(self, extension: Literal["cbz"]) -> None: + cls = {"cbz": CBZArchive}[extension] + if not isinstance(self.archive, cls): + self._archive = cls.convert_from(old_archive=self.archive) + + def clean_archive(self) -> None: + for filename in self.archive.list_filenames(): + filepath = Path(filename) + if ( + filepath.name not in METADATA_FILENAMES + and filepath.suffix.lower() not in IMAGE_EXTENSIONS + ): + self.archive.remove_file(filename=filename) + LOGGER.info("Removed '%s' from '%s'", filename, self.filepath.name) + + def write_metadata(self, metadata: Metadata) -> None: + if isinstance(metadata, ComicInfo): + self.archive.write_file(filename="ComicInfo.xml", data=metadata.to_bytes()) + self._metadata["ComicInfo"] = metadata + if isinstance(metadata, MetronInfo): + self.archive.write_file(filename="MetronInfo.xml", data=metadata.to_bytes()) + self._metadata["MetronInfo"] = metadata + + def _get_filepath_from_metadata(self, naming: Naming) -> str | None: + if self.metron_info: + return self.metron_info.get_filename(settings=naming) + if self.comic_info: + return self.comic_info.get_filename(settings=naming) + return None + + def _rename_images(self, base_name: str) -> None: + files = [ + x for x in self.archive.list_filenames() if Path(x).suffix.lower() in IMAGE_EXTENSIONS + ] + if all(x.startswith(base_name) for x in files): + return + files = humansorted(files, alg=ns.NA | ns.G | ns.P) + pad_count = len(str(len(files))) if files else 1 + for idx, filename in enumerate(files): + img_file = Path(filename) + new_file = img_file.with_stem(f"{base_name}_{str(idx).zfill(pad_count)}") + if new_file.stem != img_file.stem: + LOGGER.info("Renaming '%s' to '%s'", img_file.name, new_file.name) + file_contents = self.archive.read_file(filename=filename) + self.archive.remove_file(filename=filename) + self.archive.write_file(filename=new_file.name, data=file_contents) + + def rename(self, naming: Naming, output_folder: Path) -> None: + new_filepath = self._get_filepath_from_metadata(naming=naming) + if new_filepath is None: + LOGGER.warning("Not enough information to rename '%s', skipping", self.filepath.name) + return + new_filepath = new_filepath.lstrip("/") + + output = output_folder / f"{new_filepath}.cbz" + self._rename_images(base_name=output.stem) + if output.relative_to(output_folder) == self.filepath.resolve().relative_to(output_folder): + return + if output.exists(): + LOGGER.warning("'%s' already exists, skipping", output.relative_to(output_folder)) + return + output.parent.mkdir(parents=True, exist_ok=True) + + LOGGER.info( + "Renaming '%s' to '%s'", self.filepath.name, output.relative_to(output_folder.parent) + ) + shutil.move(self.filepath, output) + self._archive = Archive.load(filepath=output) diff --git a/perdoo/comic/errors.py b/perdoo/comic/errors.py new file mode 100644 index 0000000..6bb0e53 --- /dev/null +++ b/perdoo/comic/errors.py @@ -0,0 +1,10 @@ +__all__ = ["ComicArchiveError", "ComicError", "ComicMetadataError"] + + +class ComicError(Exception): ... + + +class ComicArchiveError(ComicError): ... + + +class ComicMetadataError(ComicError): ... diff --git a/perdoo/comic/metadata/__init__.py b/perdoo/comic/metadata/__init__.py new file mode 100644 index 0000000..627c792 --- /dev/null +++ b/perdoo/comic/metadata/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["ComicInfo", "Metadata", "MetronInfo"] + +from perdoo.comic.metadata._base import Metadata +from perdoo.comic.metadata.comic_info import ComicInfo +from perdoo.comic.metadata.metron_info import MetronInfo diff --git a/perdoo/metadata/_base.py b/perdoo/comic/metadata/_base.py similarity index 91% rename from perdoo/metadata/_base.py rename to perdoo/comic/metadata/_base.py index 9bde05a..f3c1e98 100644 --- a/perdoo/metadata/_base.py +++ b/perdoo/comic/metadata/_base.py @@ -1,7 +1,8 @@ -__all__ = ["PascalModel", "sanitize"] +__all__ = ["Metadata", "PascalModel", "sanitize"] import logging import re +from abc import ABC, abstractmethod from collections.abc import Callable from pathlib import Path from typing import Literal @@ -11,6 +12,7 @@ from rich.panel import Panel from perdoo.console import CONSOLE +from perdoo.settings import Naming from perdoo.utils import flatten_dict try: @@ -42,6 +44,13 @@ class PascalModel( skip_empty=True, search_mode="unordered", ): + pass + + +class Metadata(PascalModel, ABC): + @abstractmethod + def get_filename(self, settings: Naming) -> str: ... + @classmethod def from_bytes(cls, content: bytes) -> Self: return cls.from_xml(content) diff --git a/perdoo/metadata/comic_info.py b/perdoo/comic/metadata/comic_info.py similarity index 98% rename from perdoo/metadata/comic_info.py rename to perdoo/comic/metadata/comic_info.py index 0953646..02c936c 100644 --- a/perdoo/metadata/comic_info.py +++ b/perdoo/comic/metadata/comic_info.py @@ -9,7 +9,7 @@ from pydantic import HttpUrl, NonNegativeFloat from pydantic_xml import attr, computed_attr, element, wrapped -from perdoo.metadata._base import PascalModel +from perdoo.comic.metadata._base import Metadata, PascalModel from perdoo.settings import Naming LOGGER = logging.getLogger(__name__) @@ -140,7 +140,7 @@ def __hash__(self) -> int: return hash((type(self), self.image)) -class ComicInfo(PascalModel): +class ComicInfo(Metadata): age_rating: AgeRating = element(default=AgeRating.UNKNOWN) alternate_count: int | None = element(default=None) alternate_number: str | None = element(default=None) @@ -278,7 +278,7 @@ def story_arc_list(self, value: list[str]) -> None: self.story_arc = list_to_str(value=value) def get_filename(self, settings: Naming) -> str: - from perdoo.metadata.metron_info import Format # noqa: PLC0415 + from perdoo.comic.metadata.metron_info import Format # noqa: PLC0415 return self.evaluate_pattern( pattern_map=PATTERN_MAP, diff --git a/perdoo/metadata/metron_info.py b/perdoo/comic/metadata/metron_info.py similarity index 99% rename from perdoo/metadata/metron_info.py rename to perdoo/comic/metadata/metron_info.py index bddfdf9..6223a46 100644 --- a/perdoo/metadata/metron_info.py +++ b/perdoo/comic/metadata/metron_info.py @@ -27,7 +27,7 @@ from pydantic import HttpUrl, NonNegativeInt, PositiveInt, field_validator from pydantic_xml import attr, computed_attr, element, wrapped -from perdoo.metadata._base import PascalModel +from perdoo.comic.metadata._base import Metadata, PascalModel from perdoo.settings import Naming LOGGER = logging.getLogger(__name__) @@ -317,7 +317,7 @@ def __hash__(self) -> int: return hash((type(self), self.value)) -class MetronInfo(PascalModel): +class MetronInfo(Metadata): age_rating: AgeRating = element(default=AgeRating.UNKNOWN) arcs: list[Arc] = wrapped(path="Arcs", entity=element(tag="Arc", default_factory=list)) characters: list[Resource[str]] = wrapped( diff --git a/perdoo/metadata/__init__.py b/perdoo/metadata/__init__.py deleted file mode 100644 index 81f97d4..0000000 --- a/perdoo/metadata/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -__all__ = ["ComicInfo", "MetronInfo"] - -from perdoo.metadata.comic_info import ComicInfo -from perdoo.metadata.metron_info import MetronInfo diff --git a/perdoo/services/_base.py b/perdoo/services/_base.py index 81fafe9..c25ef93 100644 --- a/perdoo/services/_base.py +++ b/perdoo/services/_base.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import Generic, TypeVar -from perdoo.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata import ComicInfo, MetronInfo from perdoo.utils import IssueSearch, Search, SeriesSearch S = TypeVar("S") diff --git a/perdoo/services/comicvine.py b/perdoo/services/comicvine.py index 3b0f0e1..11b4625 100644 --- a/perdoo/services/comicvine.py +++ b/perdoo/services/comicvine.py @@ -15,8 +15,8 @@ from simyan.sqlite_cache import SQLiteCache from perdoo import get_cache_root -from perdoo.metadata import ComicInfo, MetronInfo -from perdoo.metadata.metron_info import InformationSource +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata.metron_info import InformationSource from perdoo.services._base import BaseService from perdoo.settings import Comicvine as ComicvineSettings from perdoo.utils import IssueSearch, Search, SeriesSearch @@ -77,7 +77,9 @@ def _search_series( if selected and selected != DEFAULT_CHOICE.title: return selected.id else: - LOGGER.warning("Unable to find any Volumes for the file: '%s'", filename) + LOGGER.warning( + "Unable to find any Volumes on Comicvine for the file: '%s'", filename + ) if year: LOGGER.info("Searching again without the StartYear") return self._search_series(name=name, volume=volume, year=None, filename=filename) @@ -144,7 +146,9 @@ def _search_issue(self, series_id: int, number: str | None, filename: str) -> in if selected and selected != DEFAULT_CHOICE.title: return selected.id else: - LOGGER.warning("Unable to find any Issues for the file: '%s'", filename) + LOGGER.warning( + "Unable to find any Issues on Comicvine for the file: '%s'", filename + ) if number: LOGGER.info("Searching again without the IssueNumber") return self._search_issue(series_id=series_id, number=None, filename=filename) @@ -175,7 +179,7 @@ def fetch_issue(self, series_id: int, search: IssueSearch, filename: str) -> Iss return None def _process_metron_info(self, series: Volume, issue: Issue) -> MetronInfo | None: - from perdoo.metadata.metron_info import ( # noqa: PLC0415 + from perdoo.comic.metadata.metron_info import ( # noqa: PLC0415 Arc, Credit, Id, diff --git a/perdoo/services/metron.py b/perdoo/services/metron.py index cc906e6..88f5bab 100644 --- a/perdoo/services/metron.py +++ b/perdoo/services/metron.py @@ -13,8 +13,8 @@ from questionary import Choice, confirm, select, text from perdoo import get_cache_root -from perdoo.metadata import ComicInfo, MetronInfo -from perdoo.metadata.metron_info import InformationSource +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata.metron_info import InformationSource from perdoo.services._base import BaseService from perdoo.settings import Metron as MetronSettings from perdoo.utils import IssueSearch, Search, SeriesSearch @@ -81,7 +81,7 @@ def _search_series( if selected and selected != DEFAULT_CHOICE.title: return selected.id else: - LOGGER.warning("Unable to find any Series for the file: '%s'", filename) + LOGGER.warning("Unable to find any Series on Metron for the file: '%s'", filename) if year: LOGGER.info("Searching again without the YearBegan") return self._search_series(name=name, volume=volume, year=None, filename=filename) @@ -158,7 +158,7 @@ def _search_issue(self, series_id: int, number: str | None, filename: str) -> in if selected and selected != DEFAULT_CHOICE.title: return selected.id else: - LOGGER.warning("Unable to find any Comics for the file: '%s'", filename) + LOGGER.warning("Unable to find any Comics on Metron for the file: '%s'", filename) if number: LOGGER.info("Searching again without the Number") return self._search_issue(series_id=series_id, number=None, filename=filename) @@ -186,7 +186,7 @@ def fetch_issue(self, series_id: int, search: IssueSearch, filename: str) -> Iss return None def _process_metron_info(self, series: Series, issue: Issue) -> MetronInfo | None: - from perdoo.metadata.metron_info import ( # noqa: PLC0415 + from perdoo.comic.metadata.metron_info import ( # noqa: PLC0415 GTIN, AgeRating, Arc, @@ -262,7 +262,7 @@ def load_role(value: str) -> Role: ) def _process_comic_info(self, series: Series, issue: Issue) -> ComicInfo | None: - from perdoo.metadata.comic_info import AgeRating # noqa: PLC0415 + from perdoo.comic.metadata.comic_info import AgeRating # noqa: PLC0415 def load_age_rating(value: str) -> AgeRating: try: diff --git a/perdoo/settings.py b/perdoo/settings.py index c4808da..ed38a43 100644 --- a/perdoo/settings.py +++ b/perdoo/settings.py @@ -74,7 +74,6 @@ class Naming(SettingsModel): class Output(SettingsModel): comic_info: ComicInfo = ComicInfo() folder: Path = get_data_root() - format: Literal["cbt", "cbz"] = "cbz" metron_info: MetronInfo = MetronInfo() naming: Naming = Naming() diff --git a/pyproject.toml b/pyproject.toml index d79c12f..c8cb005 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ requires = ["hatchling"] [dependency-groups] dev = [ - "pre-commit >= 4.4.0" + "pre-commit >= 4.5.0" ] tests = [ "pytest >= 9.0.0", "pytest-cov >= 7.0.0", - "tox >= 4.32.0", + "tox >= 4.34.0", "tox-uv >= 1.29.0" ] @@ -33,19 +33,19 @@ classifiers = [ ] dependencies = [ "comicfn2dict >= 0.2.0", - "darkseid >= 7.1.0", - "lxml >= 6.0.0", - "mokkari >= 3.14.0", + "mokkari >= 3.17.0", "natsort >= 8.4.0", - "pillow >= 12.0.0", + "pillow >= 12.1.0", "pydantic >= 2.12.0", - "pydantic-xml >= 2.18.0", + "pydantic-xml[lxml] >= 2.18.0", "questionary >= 2.1.0", + "rarfile >= 4.2", "rich >= 14.2.0", "simyan >= 1.6.0", - "tomli >= 2.3.0 ; python_version < '3.11'", + "tomli >= 2.4.0 ; python_version < '3.11'", "tomli-w >= 1.2.0", - "typer >= 0.20.0" + "typer >= 0.21.0", + "zipremove >= 0.8.0" ] description = "Unify and organize your comic collection." dynamic = ["version"] @@ -58,7 +58,7 @@ requires-python = ">= 3.10" [project.optional-dependencies] cb7 = [ - "darkseid[7zip] >= 7.1.0" + "py7zr >= 1.1.0" ] [project.scripts] diff --git a/tests/comic_test.py b/tests/comic_test.py index 768a5b6..3f2970a 100644 --- a/tests/comic_test.py +++ b/tests/comic_test.py @@ -1,26 +1,37 @@ +import tarfile from pathlib import Path from unittest.mock import MagicMock import pytest +from zipremove import ZipFile -from perdoo.comic import Comic, ComicMetadataError -from perdoo.metadata import ComicInfo, MetronInfo -from perdoo.metadata.metron_info import Series +from perdoo.comic import Comic +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata.metron_info import Series from perdoo.settings import Naming @pytest.fixture -def cbz_comic(tmp_path: Path) -> Comic: - file_path = tmp_path / "test.cbz" - file_path.touch() - return Comic(file=file_path) +def image_file(tmp_path: Path) -> Path: + filepath = tmp_path / "page1.jpg" + filepath.write_bytes(b"Fake image") + return filepath @pytest.fixture -def cbt_comic(tmp_path: Path) -> Comic: - file_path = tmp_path / "test.cbt" - file_path.touch() - return Comic(file=file_path) +def cbz_comic(tmp_path: Path, image_file: Path) -> Comic: + filepath = tmp_path / "test.cbz" + with ZipFile(filepath, "w") as archive: + archive.write(image_file) + return Comic(filepath=filepath) + + +@pytest.fixture +def cbt_comic(tmp_path: Path, image_file: Path) -> Comic: + filepath = tmp_path / "test.cbt" + with tarfile.open(filepath, "w:gz") as archive: + archive.add(image_file) + return Comic(filepath=filepath) @pytest.fixture @@ -34,24 +45,19 @@ def comic_info() -> ComicInfo: def test_convert_to_cbz(cbt_comic: Comic) -> None: - cbt_comic.convert(extension="cbz") - assert cbt_comic.path.suffix == ".cbz" - - -def test_convert_to_cbt(cbz_comic: Comic) -> None: - cbz_comic.convert(extension="cbt") - assert cbz_comic.path.suffix == ".cbt" + cbt_comic.convert_to(extension="cbz") + assert cbt_comic.filepath.suffix == ".cbz" def test_clean_archive(cbz_comic: Comic) -> None: - cbz_comic._archiver.get_filename_list = MagicMock( # noqa: SLF001 + cbz_comic.archive.list_filenames = MagicMock( return_value=["image1.jpg", "info.txt", "ComicInfo.xml", "cover.png"] ) - cbz_comic._archiver.remove_files = MagicMock() # noqa: SLF001 + cbz_comic.archive.remove_file = MagicMock() cbz_comic.clean_archive() - cbz_comic._archiver.remove_files.assert_called_once() # noqa: SLF001 - cbz_comic._archiver.remove_files.assert_called_once_with(filename_list=["info.txt"]) # noqa: SLF001 + cbz_comic.archive.remove_file.assert_called_once() + cbz_comic.archive.remove_file.assert_called_once_with(filename="info.txt") def test_write_comicinfo(cbz_comic: Comic, comic_info: ComicInfo) -> None: @@ -66,13 +72,6 @@ def test_write_metroninfo(cbz_comic: Comic, metron_info: MetronInfo) -> None: assert cbz_comic.metron_info == metron_info -def test_write_null_metadata(cbz_comic: Comic) -> None: - with pytest.raises(ComicMetadataError): - cbz_comic.write_metadata(metadata=None) - assert cbz_comic.comic_info is None - assert cbz_comic.metron_info is None - - def test_write_metadata_override(cbz_comic: Comic, metron_info: MetronInfo) -> None: metadata_copy = metron_info.model_copy(deep=True) metadata_copy.series.volume = 2 @@ -86,5 +85,5 @@ def test_write_metadata_override(cbz_comic: Comic, metron_info: MetronInfo) -> N def test_rename(cbz_comic: Comic, metron_info: MetronInfo) -> None: cbz_comic.write_metadata(metadata=metron_info) - cbz_comic.rename(naming=Naming(), output_folder=cbz_comic.path.parent) - assert cbz_comic.path.name == "Test-Series-v1_#.cbz" + cbz_comic.rename(naming=Naming(), output_folder=cbz_comic.filepath.parent) + assert cbz_comic.filepath.name == "Test-Series-v1_#.cbz" diff --git a/tests/naming_test.py b/tests/naming_test.py index 74e9bc7..a7d3e15 100644 --- a/tests/naming_test.py +++ b/tests/naming_test.py @@ -1,6 +1,6 @@ -from perdoo.metadata._base import sanitize -from perdoo.metadata.comic_info import ComicInfo -from perdoo.metadata.metron_info import Format, MetronInfo, Publisher, Series +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata._base import sanitize +from perdoo.comic.metadata.metron_info import Format, Publisher, Series from perdoo.settings import Naming diff --git a/uv.lock b/uv.lock index f985e77..a6b078a 100644 --- a/uv.lock +++ b/uv.lock @@ -539,37 +539,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "darkseid" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "defusedxml" }, - { name = "natsort" }, - { name = "pycountry" }, - { name = "rarfile" }, - { name = "xmlschema" }, - { name = "zipremove" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/dc/9497d57c8a9b0894f2f83d338c56cc85cb33c147ce9df9f37749f55e763d/darkseid-7.2.2.tar.gz", hash = "sha256:1aad3d6af2afab24b7ad637809cb4107c8bf50e4003f72497cb3c9ca1481fdfd", size = 112278, upload-time = "2026-01-09T14:44:09.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/0a/637a5ff917f25ab8ab93cef0624d874cbbe2fc444cddc2a6f88d23600951/darkseid-7.2.2-py3-none-any.whl", hash = "sha256:7bd4e27875b369d02d116659f59c6f1fdfec9373ab84891d0afcdc426a1d790a", size = 92489, upload-time = "2026-01-09T14:44:09.913Z" }, -] - -[package.optional-dependencies] -7zip = [ - { name = "py7zr" }, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -579,15 +548,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "elementpath" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/58/f35321da5e1e49e3ce2cb95dabbaf07ac02226544c8f00d4fd7580704f9a/elementpath-5.1.0.tar.gz", hash = "sha256:61618f64686ce73cf6f191b17298e2568e9a1763b125fc7f2cb796ad0eacfd1e", size = 374413, upload-time = "2025-12-28T12:07:16.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/ab/c3e1eb3841b4d3b993761b5cea4b6d6482faaa4887166286b5b4d782b4c9/elementpath-5.1.0-py3-none-any.whl", hash = "sha256:41aa6f08f1e7b02f3ae3f8ab56f84d0a564e9214a56e63fc34e6fd03f3c8f01d", size = 259537, upload-time = "2025-12-28T12:07:13.71Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -904,24 +864,24 @@ name = "perdoo" source = { editable = "." } dependencies = [ { name = "comicfn2dict" }, - { name = "darkseid" }, - { name = "lxml" }, { name = "mokkari" }, { name = "natsort" }, { name = "pillow" }, { name = "pydantic" }, - { name = "pydantic-xml" }, + { name = "pydantic-xml", extra = ["lxml"] }, { name = "questionary" }, + { name = "rarfile" }, { name = "rich" }, { name = "simyan" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomli-w" }, { name = "typer" }, + { name = "zipremove" }, ] [package.optional-dependencies] cb7 = [ - { name = "darkseid", extra = ["7zip"] }, + { name = "py7zr" }, ] [package.dev-dependencies] @@ -938,29 +898,29 @@ tests = [ [package.metadata] requires-dist = [ { name = "comicfn2dict", specifier = ">=0.2.0" }, - { name = "darkseid", specifier = ">=7.1.0" }, - { name = "darkseid", extras = ["7zip"], marker = "extra == 'cb7'", specifier = ">=7.1.0" }, - { name = "lxml", specifier = ">=6.0.0" }, - { name = "mokkari", specifier = ">=3.14.0" }, + { name = "mokkari", specifier = ">=3.17.0" }, { name = "natsort", specifier = ">=8.4.0" }, - { name = "pillow", specifier = ">=12.0.0" }, + { name = "pillow", specifier = ">=12.1.0" }, + { name = "py7zr", marker = "extra == 'cb7'", specifier = ">=1.1.0" }, { name = "pydantic", specifier = ">=2.12.0" }, - { name = "pydantic-xml", specifier = ">=2.18.0" }, + { name = "pydantic-xml", extras = ["lxml"], specifier = ">=2.18.0" }, { name = "questionary", specifier = ">=2.1.0" }, + { name = "rarfile", specifier = ">=4.2" }, { name = "rich", specifier = ">=14.2.0" }, { name = "simyan", specifier = ">=1.6.0" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.3.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.4.0" }, { name = "tomli-w", specifier = ">=1.2.0" }, - { name = "typer", specifier = ">=0.20.0" }, + { name = "typer", specifier = ">=0.21.0" }, + { name = "zipremove", specifier = ">=0.8.0" }, ] provides-extras = ["cb7"] [package.metadata.requires-dev] -dev = [{ name = "pre-commit", specifier = ">=4.4.0" }] +dev = [{ name = "pre-commit", specifier = ">=4.5.0" }] tests = [ { name = "pytest", specifier = ">=9.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, - { name = "tox", specifier = ">=4.32.0" }, + { name = "tox", specifier = ">=4.34.0" }, { name = "tox-uv", specifier = ">=1.29.0" }, ] @@ -1219,15 +1179,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/5d/7a87ba32c0c0756f36000fafe642fa4609be2c26a50a7913a057a47eabdf/pybcj-1.0.7-cp314-cp314t-win_arm64.whl", hash = "sha256:16fd4e51a5556d1f38d7ba5d1fab588bfb60ae23d2299b5179779bf9900adf71", size = 24049, upload-time = "2025-11-29T00:53:28.679Z" }, ] -[[package]] -name = "pycountry" -version = "24.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -1418,6 +1369,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/cd/6a9174b5a432ef4f49e271418104b62a0da2881cc6dfc6b73dd20498931e/pydantic_xml-2.18.0-py3-none-any.whl", hash = "sha256:9b2412c8c84242223979e9274ade1d3566028cf6a9b1cdb6389384d2db5292c0", size = 42484, upload-time = "2025-10-10T20:12:42.258Z" }, ] +[package.optional-dependencies] +lxml = [ + { name = "lxml" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1889,18 +1845,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] -[[package]] -name = "xmlschema" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elementpath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/bb/3dbd57318bab855dd9c70d6e7d79da27cd4dc34118f59451e86a3727c847/xmlschema-4.3.0.tar.gz", hash = "sha256:174c531dd869cd29bf2d1203603d9e619bddf168d6289725738914c96c80936e", size = 645232, upload-time = "2026-01-06T11:22:15.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/98/f7ee16a9350060a8ef9aacedff492311343e580ab0cd2897f8d0e726a50d/xmlschema-4.3.0-py3-none-any.whl", hash = "sha256:2b97df551039519eed0eaf015f2ea523b958f64917a6ea4303d7025169fa423e", size = 468972, upload-time = "2026-01-06T11:22:13.4Z" }, -] - [[package]] name = "zipremove" version = "0.8.0"