From 3e6fa245981d9c1735e419a6e4f8bc40e5fa5b41 Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:38:50 +1300 Subject: [PATCH 01/12] Go back to using my own archive implementation Drop support for exporting as CBT --- .pre-commit-config.yaml | 2 +- README.md | 5 - perdoo/__main__.py | 31 ++-- perdoo/cli/archive.py | 4 +- perdoo/comic.py | 198 --------------------- perdoo/comic/__init__.py | 3 + perdoo/comic/archive/__init__.py | 7 + perdoo/comic/archive/_base.py | 67 +++++++ perdoo/comic/archive/rar.py | 43 +++++ perdoo/comic/archive/sevenzip.py | 60 +++++++ perdoo/comic/archive/tar.py | 42 +++++ perdoo/comic/archive/zip.py | 106 +++++++++++ perdoo/comic/comic.py | 118 ++++++++++++ perdoo/comic/errors.py | 10 ++ perdoo/comic/metadata/__init__.py | 5 + perdoo/{ => comic}/metadata/_base.py | 11 +- perdoo/{ => comic}/metadata/comic_info.py | 4 +- perdoo/{ => comic}/metadata/metron_info.py | 4 +- perdoo/metadata/__init__.py | 4 - perdoo/services/_base.py | 2 +- perdoo/services/comicvine.py | 14 +- perdoo/services/metron.py | 12 +- perdoo/settings.py | 1 - pyproject.toml | 20 +-- tests/comic_test.py | 7 +- tests/naming_test.py | 6 +- uv.lock | 94 ++-------- 27 files changed, 544 insertions(+), 336 deletions(-) delete mode 100644 perdoo/comic.py create mode 100644 perdoo/comic/__init__.py create mode 100644 perdoo/comic/archive/__init__.py create mode 100644 perdoo/comic/archive/_base.py create mode 100644 perdoo/comic/archive/rar.py create mode 100644 perdoo/comic/archive/sevenzip.py create mode 100644 perdoo/comic/archive/tar.py create mode 100644 perdoo/comic/archive/zip.py create mode 100644 perdoo/comic/comic.py create mode 100644 perdoo/comic/errors.py create mode 100644 perdoo/comic/metadata/__init__.py rename perdoo/{ => comic}/metadata/_base.py (91%) rename perdoo/{ => comic}/metadata/comic_info.py (99%) rename perdoo/{ => comic}/metadata/metron_info.py (99%) delete mode 100644 perdoo/metadata/__init__.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3888d7..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.14 + 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 99% rename from perdoo/metadata/comic_info.py rename to perdoo/comic/metadata/comic_info.py index 0953646..3bed558 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) 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..a6f3b8c 100644 --- a/tests/comic_test.py +++ b/tests/comic_test.py @@ -3,9 +3,10 @@ import pytest -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.errors import ComicMetadataError +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata.metron_info import Series from perdoo.settings import Naming 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 a68992f..36ae49b 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.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/95/eeb61a2a917bf506d1402748e71c62399d8dcdd8cdccd64c81962832c260/elementpath-5.1.1.tar.gz", hash = "sha256:c4d1bd6aed987258354d0ea004d965eb0a6818213326bd4fd9bde5dacdb20277", size = 375378, upload-time = "2026-01-20T21:42:27.175Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/5a/4ddfd9aeecdc75a78b5d85d882abc2b822115caae2c00a4d0eb23cc101fc/elementpath-5.1.1-py3-none-any.whl", hash = "sha256:57637359065e60654422d8474c1749b09bb21348012b7197f868027e3c09c9b9", size = 259962, upload-time = "2026-01-20T21:42:24.127Z" }, -] - [[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 = "3.0" @@ -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/b7/f6/da704c5e77281d71723bffbd926b754c0efd57cbcd02e74c2ca374c14cef/wcwidth-0.4.0-py3-none-any.whl", hash = "sha256:8af2c81174b3aa17adf05058c543c267e4e5b6767a28e31a673a658c1d766783", size = 88216, upload-time = "2026-01-26T02:35:57.461Z" }, ] -[[package]] -name = "xmlschema" -version = "4.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elementpath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/c4/ef78a231be72349fd6677b989ff80e276ef62e28054c36c4fea3b4db9611/xmlschema-4.3.1.tar.gz", hash = "sha256:853effdfaf127849d4724368c17bd669e7f1486e15a0376404ad7954ec31a338", size = 646611, upload-time = "2026-01-17T23:01:04.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/7b/3471405875d0b5fac642e9a879b2c7db63642370799b2e9eea8297ffbad0/xmlschema-4.3.1-py3-none-any.whl", hash = "sha256:9560314d70ae87be0aecb8712cfebed636f867707ccf9758d4b0645d607f64b9", size = 469891, upload-time = "2026-01-17T23:01:00.39Z" }, -] - [[package]] name = "zipremove" version = "0.8.0" From ddd6c3b70c10df2bd52cf8912c96224ff3b0876a Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:22:18 +1300 Subject: [PATCH 02/12] Fix tests --- perdoo/comic/metadata/comic_info.py | 2 +- tests/comic_test.py | 56 ++++++++++++++--------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/perdoo/comic/metadata/comic_info.py b/perdoo/comic/metadata/comic_info.py index 3bed558..02c936c 100644 --- a/perdoo/comic/metadata/comic_info.py +++ b/perdoo/comic/metadata/comic_info.py @@ -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/tests/comic_test.py b/tests/comic_test.py index a6f3b8c..3f2970a 100644 --- a/tests/comic_test.py +++ b/tests/comic_test.py @@ -1,27 +1,37 @@ +import tarfile from pathlib import Path from unittest.mock import MagicMock import pytest +from zipremove import ZipFile from perdoo.comic import Comic -from perdoo.comic.errors import ComicMetadataError 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 @@ -35,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: @@ -67,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 @@ -87,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" From 190eb16e652359715576be26063757094141d311 Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 07:35:24 +1300 Subject: [PATCH 03/12] Refactor archives to have less extracts --- README.md | 11 +- perdoo/__main__.py | 170 ++++++++++++--------------- perdoo/comic/__init__.py | 4 +- perdoo/comic/archive/_base.py | 15 ++- perdoo/comic/archive/rar.py | 3 + perdoo/comic/archive/session.py | 98 +++++++++++++++ perdoo/comic/archive/sevenzip.py | 3 + perdoo/comic/archive/tar.py | 37 ++++++ perdoo/comic/archive/zip.py | 38 +++++- perdoo/comic/comic.py | 126 +++++++------------- perdoo/comic/metadata/_base.py | 4 +- perdoo/comic/metadata/comic_info.py | 3 + perdoo/comic/metadata/metron_info.py | 4 +- perdoo/processing.py | 97 +++++++++++++++ perdoo/settings.py | 22 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 17 files changed, 433 insertions(+), 206 deletions(-) create mode 100644 perdoo/comic/archive/session.py create mode 100644 perdoo/processing.py diff --git a/README.md b/README.md index a0bb211..72bea58 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Unlike other tagging tools, Perdoo employs a manual approach when metadata files ### Output Extensions - .cbz +- .cbt +- .cb7 _(Requires installing `cb7` dependencies: `pipx install perdoo[cb7]`)_ ### Metadata Files @@ -144,6 +146,7 @@ File will be created on first run. ```toml [output] folder = "~/.local/share/perdoo" +format = "cbz" [output.comic_info] create = true @@ -183,6 +186,11 @@ 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`. + Options are `cbz`, `cbt` or `cb7` + - `output.comic_info.create` Whether to create a ComicInfo.xml file in the output archive. Defaults to `true`. @@ -209,7 +217,8 @@ password = "" The order in which the services will be used for metadata retrieval. Metadata will be fetched from the first service that returns a result. Don't include the service name in the list if you don't want to use it. - Defaults to `["Metron", "Comicvine"]`, options are `Metron` and `Comicvine`. + Defaults to `["Metron", "Comicvine"]`. + Options are `Metron` or `Comicvine`. ## Socials diff --git a/perdoo/__main__.py b/perdoo/__main__.py index 91c97e8..a220c32 100644 --- a/perdoo/__main__.py +++ b/perdoo/__main__.py @@ -1,7 +1,6 @@ import logging from datetime import date from enum import Enum -from io import BytesIO from pathlib import Path from platform import python_version from typing import Annotated @@ -14,9 +13,9 @@ 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.processing import ProcessingPlan from perdoo.services import BaseService, Comicvine, Metron from perdoo.settings import Service, Services, Settings from perdoo.utils import ( @@ -70,7 +69,25 @@ def get_services(settings: Services) -> dict[Service, BaseService]: return output -def _load_comics(target: Path) -> list[Comic]: +def setup_environment( + clean_cache: bool, sync: SyncOption, settings: Settings, debug: bool = False +) -> tuple[dict[Service, BaseService], SyncOption]: + setup_logging(debug=debug) + LOGGER.info("Python v%s", python_version()) + LOGGER.info("Perdoo v%s", __version__) + + if clean_cache: + LOGGER.info("Cleaning Cache") + recursive_delete(path=get_cache_root()) + + services = get_services(settings=settings.services) + if not services and sync != SyncOption.SKIP: + LOGGER.warning("No external services configured") + sync = SyncOption.SKIP + return services, sync + + +def load_comics(target: Path) -> list[Comic]: comics = [] files = list_files(target) if target.is_dir() else [target] for file in files: @@ -81,11 +98,31 @@ def _load_comics(target: Path) -> list[Comic]: return comics +def prepare_comic(entry: Comic, settings: Settings, skip_convert: bool) -> bool: + if not skip_convert: + entry.convert_to(settings.output.format) + if not entry.archive.IS_WRITEABLE: + LOGGER.warning("Archive format %s is not writeable", entry.archive.EXTENSION) + return False + return True + + +def should_sync_metadata(sync: SyncOption, metroninfo: MetronInfo | None) -> bool: + if sync == SyncOption.SKIP: + return False + if sync == SyncOption.FORCE: + return True + if metroninfo and metroninfo.last_modified: + age = (date.today() - metroninfo.last_modified.date()).days + return age >= 28 + return True + + def _get_id_value(ids: list[Id], source: InformationSource) -> str | None: return next((x.value for x in ids if x.source == source), None) -def _create_search_from_metron(metron_info: MetronInfo) -> Search: +def _create_search_from_metron_info(metron_info: MetronInfo) -> Search: series_id = metron_info.series.id source = next((x.source for x in metron_info.ids if x.primary), None) return Search( @@ -124,46 +161,12 @@ def get_search_details( ) -> Search: metron_info, comic_info = metadata if metron_info and metron_info.series and metron_info.series.name: - return _create_search_from_metron(metron_info=metron_info) + return _create_search_from_metron_info(metron_info=metron_info) if comic_info and comic_info.series: return _create_search_from_comic_info(comic_info=comic_info, filename=filename) return _create_search_from_filename(filename=filename) -def load_page_info(entry: Comic, comic_info: ComicInfo) -> list[Page]: - from PIL import Image # 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.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) - if page: - page_type = page.type - elif idx == 0: - page_type = PageType.FRONT_COVER - elif idx == len(image_files) - 1: - page_type = PageType.BACK_COVER - else: - page_type = PageType.STORY - if not page: - page = Page(image=idx) - page.type = page_type - page_bytes = entry.archive.read_file(file) - page.image_size = len(page_bytes) - with Image.open(BytesIO(page_bytes)) as page_data: - width, height = page_data.size - page.double_page = width >= height - page.image_height = height - page.image_width = width - pages.add(page) - return sorted(pages) - - def sync_metadata( search: Search, services: dict[Service, BaseService | None], settings: Settings ) -> tuple[MetronInfo | None, ComicInfo | None]: @@ -175,6 +178,17 @@ def sync_metadata( return None, None +def resolve_metadata( + entry: Comic, services: dict[Service, BaseService], settings: Settings, sync: SyncOption +) -> tuple[MetronInfo | None, ComicInfo | None]: + metroninfo, comicinfo = entry.read_metadata() + if not should_sync_metadata(sync=sync, metroninfo=metroninfo): + return metroninfo, comicinfo + search = get_search_details(metadata=(metroninfo, comicinfo), filename=entry.filepath.stem) + search.filename = entry.filepath.stem + return sync_metadata(search=search, services=services, settings=settings) + + @app.command(name="import", help="Import comics into your collection using Perdoo.") def run( target: Annotated[ @@ -223,74 +237,36 @@ def run( bool, Option("--debug", help="Enable debug mode to show extra information.") ] = False, ) -> None: - setup_logging(debug=debug) - LOGGER.info("Python v%s", python_version()) - LOGGER.info("Perdoo v%s", __version__) - settings = Settings.load() settings.save() - if debug: - CONSOLE.print( - { - "target": target, - "flags.skip-convert": skip_convert, - "flags.sync": sync, - "flags.skip-clean": skip_clean, - "flags.skip-rename": skip_rename, - "flags.clean-cache": clean_cache, - } - ) - if clean_cache: - LOGGER.info("Cleaning Cache") - recursive_delete(path=get_cache_root()) - services = get_services(settings=settings.services) - if not services and sync != SyncOption.SKIP: - LOGGER.warning("No external services configured") - sync = SyncOption.SKIP + services, sync = setup_environment( + clean_cache=clean_cache, sync=sync, settings=settings, debug=debug + ) - comics = _load_comics(target=target) + comics = load_comics(target=target) for index, entry in enumerate(comics): CONSOLE.rule( f"[{index + 1}/{len(comics)}] Importing {entry.filepath.name}", align="left", style="subtitle", ) - if not skip_convert: - 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.filepath.stem) - search.filename = entry.filepath.stem - last_modified = date(1900, 1, 1) - if sync == SyncOption.OUTDATED: - metron_info, _ = metadata - if metron_info and metron_info.last_modified: - last_modified = metron_info.last_modified.date() - if (date.today() - last_modified).days >= 28: - metadata = sync_metadata(search=search, services=services, settings=settings) - else: - LOGGER.info("Metadata up-to-date") - - if not skip_clean: - with CONSOLE.status("Cleaning Archive", spinner="simpleDotsScrolling"): - entry.clean_archive() - if settings.output.metron_info.create and metadata[0]: - entry.write_metadata(metadata=metadata[0]) - if settings.output.comic_info.create and metadata[1]: - metadata[1].pages = ( - load_page_info(entry=entry, comic_info=metadata[1]) - if settings.output.comic_info.handle_pages - else [] - ) - entry.write_metadata(metadata=metadata[1]) - - if not skip_rename: - with CONSOLE.status("Renaming based on metadata", spinner="simpleDotsScrolling"): - entry.rename(naming=settings.output.naming, output_folder=settings.output.folder) + if not prepare_comic(entry=entry, settings=settings, skip_convert=skip_convert): + continue + metroninfo, comicinfo = resolve_metadata( + entry=entry, services=services, settings=settings, sync=sync + ) + plan = ProcessingPlan.build( + entry=entry, + metroninfo=metroninfo, + comicinfo=comicinfo, + settings=settings.output, + skip_clean=skip_clean, + skip_rename=skip_rename, + ) + plan.apply() + if plan.naming: + entry.move_to(naming=plan.naming, output_folder=settings.output.folder) with CONSOLE.status("Cleaning up empty folders"): delete_empty_folders(folder=target) diff --git a/perdoo/comic/__init__.py b/perdoo/comic/__init__.py index bd959e1..1c34ff7 100644 --- a/perdoo/comic/__init__.py +++ b/perdoo/comic/__init__.py @@ -1,3 +1,3 @@ -__all__ = ["IMAGE_EXTENSIONS", "Comic"] +__all__ = ["Comic"] -from perdoo.comic.comic import IMAGE_EXTENSIONS, Comic +from perdoo.comic.comic import Comic diff --git a/perdoo/comic/archive/_base.py b/perdoo/comic/archive/_base.py index d74176c..04d82a4 100644 --- a/perdoo/comic/archive/_base.py +++ b/perdoo/comic/archive/_base.py @@ -15,6 +15,9 @@ class Archive(ABC): _registry: ClassVar[list[type["Archive"]]] = [] EXTENSION: ClassVar[str] = "" + IS_READABLE: ClassVar[bool] = False + IS_WRITEABLE: ClassVar[bool] = False + IS_EDITABLE: ClassVar[bool] = False def __init__(self, filepath: Path) -> None: self._filepath = filepath @@ -41,9 +44,6 @@ 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: ... @@ -53,12 +53,17 @@ def write_file(self, filename: str, data: str | bytes) -> None: # noqa: ARG002 def remove_file(self, filename: str) -> None: raise ComicArchiveError(f"Unable to delete {filename} in {self.filepath.name}.") + def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002 + raise ComicArchiveError( + f"Unable to rename {filename} to {new_name} 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}.") + def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: # noqa: ARG003 + raise ComicArchiveError(f"Unable to archive files to {output_name}{cls.EXTENSION}.") @classmethod def convert_from(cls, old_archive: "Archive") -> Self: diff --git a/perdoo/comic/archive/rar.py b/perdoo/comic/archive/rar.py index c7ec23b..bd47802 100644 --- a/perdoo/comic/archive/rar.py +++ b/perdoo/comic/archive/rar.py @@ -14,6 +14,9 @@ class CBRArchive(Archive): EXTENSION: ClassVar[str] = ".cbr" + IS_READABLE: ClassVar[bool] = True + IS_WRITEABLE: ClassVar[bool] = False + IS_EDITABLE: ClassVar[bool] = False @classmethod def is_archive(cls, path: Path) -> bool: diff --git a/perdoo/comic/archive/session.py b/perdoo/comic/archive/session.py new file mode 100644 index 0000000..81d24e7 --- /dev/null +++ b/perdoo/comic/archive/session.py @@ -0,0 +1,98 @@ +__all__ = ["ArchiveSession"] + +import logging +import shutil +from pathlib import Path +from tempfile import TemporaryDirectory +from types import TracebackType + +from perdoo.comic.archive import Archive +from perdoo.comic.errors import ComicArchiveError +from perdoo.console import CONSOLE +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 ArchiveSession: + def __init__(self, archive: Archive) -> None: + self._archive = archive + self._temp_dir: TemporaryDirectory | None = None + self._folder: Path | None = None + self._extracted = False + + def __enter__(self) -> Self: + if self._archive.IS_EDITABLE: + return self + + self._temp_dir = TemporaryDirectory() + self._folder = Path(self._temp_dir.name) + with CONSOLE.status( + f"Extracting {self._archive.filepath} to {self._folder}", spinner="simpleDotsScrolling" + ): + self._archive.extract_files(destination=self._folder) + self._extracted = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + try: + if exc_type is None and self._extracted: + with CONSOLE.status( + f"Archiving {self._folder} to {self._archive.filepath}", + spinner="simpleDotsScrolling", + ): + filepath = self._archive.archive_files( + src=self._folder, + output_name=self._archive.filepath.stem, + files=list_files(self._folder), + ) + self._archive.filepath.unlink(missing_ok=True) + shutil.move(filepath, self._archive.filepath) + finally: + if self._temp_dir: + self._temp_dir.cleanup() + self._folder = None + self._extracted = False + + def list(self) -> list[str]: + if self._archive.IS_EDITABLE: + return self._archive.list_filenames() + return [p.name for p in self._folder.iterdir()] + + def read(self, filename: str) -> bytes: + if self._archive.IS_EDITABLE: + return self._archive.read_file(filename) + return (self._folder / filename).read_bytes() + + def write(self, filename: str, data: bytes) -> None: + if self._archive.IS_EDITABLE: + self._archive.write_file(filename, data) + else: + (self._folder / filename).write_bytes(data) + + def remove(self, filename: str) -> None: + LOGGER.info("Removing %s", filename) + if self._archive.IS_EDITABLE: + self._archive.remove_file(filename) + else: + (self._folder / filename).unlink(missing_ok=True) + + def rename(self, old_name: str, new_name: str) -> None: + LOGGER.info("Renaming %s to %s", old_name, new_name) + if self._archive.IS_EDITABLE: + self._archive.rename_file(old_name=old_name, new_name=new_name) + else: + src = self._folder / old_name + if not src.exists(): + raise ComicArchiveError(f"{old_name} does not exist") + src.rename(self._folder / new_name) diff --git a/perdoo/comic/archive/sevenzip.py b/perdoo/comic/archive/sevenzip.py index 7f31c3a..4d4a939 100644 --- a/perdoo/comic/archive/sevenzip.py +++ b/perdoo/comic/archive/sevenzip.py @@ -21,6 +21,9 @@ class CB7Archive(Archive): EXTENSION: ClassVar[str] = ".cb7" + IS_READABLE: ClassVar[bool] = True + IS_WRITEABLE: ClassVar[bool] = False + IS_EDITABLE: ClassVar[bool] = False @classmethod def is_archive(cls, path: Path) -> bool: diff --git a/perdoo/comic/archive/tar.py b/perdoo/comic/archive/tar.py index fa018e4..ab98c8c 100644 --- a/perdoo/comic/archive/tar.py +++ b/perdoo/comic/archive/tar.py @@ -1,18 +1,29 @@ __all__ = ["CBTArchive"] import logging +import shutil import tarfile from pathlib import Path +from tempfile import TemporaryDirectory from typing import ClassVar 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 CBTArchive(Archive): EXTENSION: ClassVar[str] = ".cbt" + IS_READABLE: ClassVar[bool] = True + IS_WRITEABLE: ClassVar[bool] = True + IS_EDITABLE: ClassVar[bool] = False @classmethod def is_archive(cls, path: Path) -> bool: @@ -40,3 +51,29 @@ def extract_files(self, destination: Path) -> None: archive.extractall(path=destination, filter="data") except Exception as err: raise ComicArchiveError(f"Unable to extract files from {self.filepath.name}.") from err + + @classmethod + def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: + output_file = src.parent / f"{output_name}.cbt" + try: + with tarfile.open(name=output_file, mode="w:gz") as archive: + for file in files: + archive.add(file, arcname=file.name) + return 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) + filepath = cls.archive_files( + src=temp_folder, + output_name=old_archive.filepath.stem, + files=list_files(temp_folder), + ) + new_filepath = old_archive.filepath.with_suffix(cls.EXTENSION) + old_archive.filepath.unlink(missing_ok=True) + shutil.move(filepath, new_filepath) + return cls(filepath=new_filepath) diff --git a/perdoo/comic/archive/zip.py b/perdoo/comic/archive/zip.py index b2fcfe3..e1fd574 100644 --- a/perdoo/comic/archive/zip.py +++ b/perdoo/comic/archive/zip.py @@ -22,6 +22,9 @@ class CBZArchive(Archive): EXTENSION: ClassVar[str] = ".cbz" + IS_READABLE: ClassVar[bool] = True + IS_WRITEABLE: ClassVar[bool] = True + IS_EDITABLE: ClassVar[bool] = True @classmethod def is_archive(cls, path: Path) -> bool: @@ -70,6 +73,29 @@ def remove_file(self, filename: str) -> None: f"Unable to delete {filename} from {self.filepath.name}" ) from err + def rename_file(self, old_name: str, new_name: str, override: bool = False) -> None: + if old_name not in self.list_filenames(): + raise ComicArchiveError( + f"Unable to rename {old_name} as it doesn't exist in {self.filepath.name}." + ) + try: + removed = [] + with ZipFile(file=self.filepath, mode="a") as archive: + if new_name in archive.namelist(): + if not override: + raise ComicArchiveError( # noqa: TRY301 + f"Unable to rename {old_name} as {new_name} already extsts in {self.filepath.name}." + ) + removed.append(archive.remove(new_name)) + removed.append(archive.remove(archive.copy(old_name, new_name))) + archive.repack(removed) + except ComicArchiveError: + raise + except Exception as err: + raise ComicArchiveError( + f"Unable to rename {old_name} to {new_name} in {self.filepath.name}" + ) from err + def extract_files(self, destination: Path) -> None: try: with ZipFile(file=self.filepath, mode="r") as archive: @@ -80,13 +106,13 @@ def extract_files(self, destination: Path) -> None: ) from err @classmethod - def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Self: + def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: 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) + return output_file except Exception as err: raise ComicArchiveError(f"Unable to archive files to {output_file.name}") from err @@ -95,12 +121,12 @@ 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( + filepath = 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) + new_filepath = 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) + shutil.move(filepath, new_filepath) + return cls(filepath=new_filepath) diff --git a/perdoo/comic/comic.py b/perdoo/comic/comic.py index 516cb06..1eb6ce0 100644 --- a/perdoo/comic/comic.py +++ b/perdoo/comic/comic.py @@ -5,22 +5,19 @@ 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 +from perdoo.comic.archive import Archive, CB7Archive, CBTArchive, CBZArchive +from perdoo.comic.archive.session import ArchiveSession +from perdoo.comic.metadata import ComicInfo, MetronInfo LOGGER = logging.getLogger(__name__) -METADATA_FILENAMES: Final[frozenset[str]] = frozenset(["ComicInfo.xml", "MetronInfo.xml"]) + +METADATA_FILENAMES: Final[frozenset[str]] = frozenset([MetronInfo.FILENAME, ComicInfo.FILENAME]) 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: @@ -30,89 +27,56 @@ def archive(self) -> Archive: def filepath(self) -> Path: return self.archive.filepath - @property - def comic_info(self) -> ComicInfo | None: - return self._metadata.get("ComicInfo") + def open_session(self) -> ArchiveSession: + return ArchiveSession(self.archive) - @property - def metron_info(self) -> MetronInfo | None: - return self._metadata.get("MetronInfo") + def convert_to(self, extension: Literal["cbz", "cbt", "cb7"]) -> None: + cls = {"cbz": CBZArchive, "cbt": CBTArchive, "cb7": CB7Archive}[extension] + if not isinstance(self.archive, cls): + self._archive = cls.convert_from(old_archive=self.archive) + + def contains(self, filename: str) -> bool: + return filename in self.archive.list_filenames() - 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") + def read_metadata(self) -> tuple[MetronInfo | None, ComicInfo | None]: + metroninfo = None + if self.contains(filename=MetronInfo.FILENAME): + metroninfo = MetronInfo.from_bytes( + content=self.archive.read_file(filename=MetronInfo.FILENAME) ) - if self.archive.exists(filename="MetronInfo.xml"): - self._metadata["MetronInfo"] = MetronInfo.from_bytes( - content=self.archive.read_file(filename="MetronInfo.xml") + + comicinfo = None + if self.contains(filename=ComicInfo.FILENAME): + comicinfo = ComicInfo.from_bytes( + content=self.archive.read_file(filename=ComicInfo.FILENAME) ) - 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) + return metroninfo, comicinfo - 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 + def list_images(self) -> list[Path]: + return [ + Path(name) + for name in self.archive.list_filenames() + if Path(name).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 + def list_extras(self) -> list[Path]: + return [ + Path(name) + for name in self.archive.list_filenames() + if name not in METADATA_FILENAMES and Path(name).suffix.lower() not in IMAGE_EXTENSIONS + ] + + def validate_naming(self, naming: str) -> bool: + template = Path(naming).stem + return all(img.name.startswith(template) for img in self.list_images()) + + def move_to(self, naming: str, output_folder: Path) -> None: + output = output_folder / f"{naming}{self.archive.EXTENSION}" if output.exists(): - LOGGER.warning("'%s' already exists, skipping", output.relative_to(output_folder)) + LOGGER.warning("'%s' already exists, skipping", output) return - output.parent.mkdir(parents=True, exist_ok=True) - LOGGER.info( - "Renaming '%s' to '%s'", self.filepath.name, output.relative_to(output_folder.parent) - ) + output.parent.mkdir(parents=True, exist_ok=True) shutil.move(self.filepath, output) self._archive = Archive.load(filepath=output) diff --git a/perdoo/comic/metadata/_base.py b/perdoo/comic/metadata/_base.py index f3c1e98..3363300 100644 --- a/perdoo/comic/metadata/_base.py +++ b/perdoo/comic/metadata/_base.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal from pydantic.alias_generators import to_pascal from pydantic_xml import BaseXmlModel @@ -48,6 +48,8 @@ class PascalModel( class Metadata(PascalModel, ABC): + FILENAME: ClassVar[str] = "" + @abstractmethod def get_filename(self, settings: Naming) -> str: ... diff --git a/perdoo/comic/metadata/comic_info.py b/perdoo/comic/metadata/comic_info.py index 02c936c..94c567b 100644 --- a/perdoo/comic/metadata/comic_info.py +++ b/perdoo/comic/metadata/comic_info.py @@ -4,6 +4,7 @@ from collections.abc import Callable from datetime import date from enum import Enum +from typing import ClassVar from natsort import humansorted, ns from pydantic import HttpUrl, NonNegativeFloat @@ -141,6 +142,8 @@ def __hash__(self) -> int: class ComicInfo(Metadata): + FILENAME: ClassVar[str] = "ComicInfo.xml" + age_rating: AgeRating = element(default=AgeRating.UNKNOWN) alternate_count: int | None = element(default=None) alternate_number: str | None = element(default=None) diff --git a/perdoo/comic/metadata/metron_info.py b/perdoo/comic/metadata/metron_info.py index 6223a46..35800bc 100644 --- a/perdoo/comic/metadata/metron_info.py +++ b/perdoo/comic/metadata/metron_info.py @@ -22,7 +22,7 @@ from datetime import date, datetime from decimal import Decimal from enum import Enum -from typing import Generic, TypeVar +from typing import ClassVar, Generic, TypeVar from pydantic import HttpUrl, NonNegativeInt, PositiveInt, field_validator from pydantic_xml import attr, computed_attr, element, wrapped @@ -318,6 +318,8 @@ def __hash__(self) -> int: class MetronInfo(Metadata): + FILENAME: ClassVar[str] = "MetronInfo.xml" + 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/processing.py b/perdoo/processing.py new file mode 100644 index 0000000..710c6c0 --- /dev/null +++ b/perdoo/processing.py @@ -0,0 +1,97 @@ +__all__ = ["ProcessingPlan"] + +from dataclasses import dataclass +from pathlib import Path + +from perdoo.comic import Comic +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.settings import Naming, Output + +try: + from typing import Self # Python >= 3.11 +except ImportError: + from typing_extensions import Self # Python < 3.11 + + +def generate_naming( + settings: Naming, metadata: tuple[MetronInfo | None, ComicInfo | None] +) -> str | None: + filepath = None + if metadata[0]: + filepath = metadata[0].get_filename(settings=settings) + if not filepath and metadata[1]: + filepath = metadata[1].get_filename(settings=settings) + return filepath.lstrip("/") if filepath else None + + +@dataclass +class ProcessingPlan: + comic: Comic + metroninfo: MetronInfo | None + comicinfo: ComicInfo | None + write_metron: bool + write_comic: bool + remove_extras: list[Path] + rename_images: bool + naming: str | None + + @classmethod + def build( + cls, + entry: Comic, + metroninfo: MetronInfo | None, + comicinfo: ComicInfo | None, + settings: Output, + skip_clean: bool, + skip_rename: bool, + ) -> Self: + local_metron, local_comic = entry.read_metadata() + write_metron = local_metron != metroninfo + write_comic = local_comic != comicinfo + + extras = entry.list_extras() if not skip_clean else [] + + naming = None + rename_images = False + if not skip_rename: + naming = generate_naming(settings.naming, (metroninfo, comicinfo)) + rename_images = bool(naming and not entry.validate_naming(naming)) + return cls( + comic=entry, + metroninfo=metroninfo, + comicinfo=comicinfo, + write_metron=write_metron, + write_comic=write_comic, + remove_extras=extras, + rename_images=rename_images, + naming=naming, + ) + + def apply(self) -> None: + if not (self.write_metron or self.write_comic or self.remove_extras or self.rename_images): + return + + with self.comic.open_session() as session: + if self.write_metron: + if self.metroninfo: + session.write(MetronInfo.FILENAME, self.metroninfo.to_bytes()) + else: + session.remove(MetronInfo.FILENAME) + + if self.write_comic: + if self.comicinfo: + session.write(ComicInfo.FILENAME, self.comicinfo.to_bytes()) + else: + session.remove(ComicInfo.FILENAME) + + for extra in self.remove_extras: + session.remove(extra.name) + + if self.rename_images and self.naming: + images = self.comic.list_images() + stem = Path(self.naming).stem + pad = len(str(len(images))) + for idx, img in enumerate(images): + new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" + if img.name != new_name: + session.rename(img.name, new_name) diff --git a/perdoo/settings.py b/perdoo/settings.py index ed38a43..3554cd9 100644 --- a/perdoo/settings.py +++ b/perdoo/settings.py @@ -74,6 +74,7 @@ class Naming(SettingsModel): class Output(SettingsModel): comic_info: ComicInfo = ComicInfo() folder: Path = get_data_root() + format: Literal["cbz", "cbt", "cb7"] = "cbz" metron_info: MetronInfo = MetronInfo() naming: Naming = Naming() @@ -104,16 +105,17 @@ class Services(SettingsModel): def _stringify_values(content: dict[str, Any]) -> dict[str, Any]: output = {} for key, value in content.items(): - if isinstance(value, bool): - value = str(value) - if not value: - continue - if isinstance(value, dict): - value = _stringify_values(content=value) - elif isinstance(value, list | tuple | set): - value = [_stringify_values(content=x) if isinstance(x, dict) else str(x) for x in value] - else: - value = str(value) + if not isinstance(value, bool): + if not value: + continue + if isinstance(value, dict): + value = _stringify_values(content=value) + elif isinstance(value, list | tuple | set): + value = [ + _stringify_values(content=x) if isinstance(x, dict) else str(x) for x in value + ] + else: + value = str(value) output[key] = value return output diff --git a/pyproject.toml b/pyproject.toml index c8cb005..e81accd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "pydantic-xml[lxml] >= 2.18.0", "questionary >= 2.1.0", "rarfile >= 4.2", - "rich >= 14.2.0", + "rich >= 14.3.0", "simyan >= 1.6.0", "tomli >= 2.4.0 ; python_version < '3.11'", "tomli-w >= 1.2.0", diff --git a/uv.lock b/uv.lock index 36ae49b..b67588f 100644 --- a/uv.lock +++ b/uv.lock @@ -906,7 +906,7 @@ requires-dist = [ { 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 = "rich", specifier = ">=14.3.0" }, { name = "simyan", specifier = ">=1.6.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.4.0" }, { name = "tomli-w", specifier = ">=1.2.0" }, From 1e5855bb3bcb81f84121827d63ed3a8bed4bb808 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 26 Jan 2026 18:36:08 +0000 Subject: [PATCH 04/12] Generate new screengrabs with rich-codex --- docs/img/perdoo-commands.svg | 90 ++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/img/perdoo-commands.svg b/docs/img/perdoo-commands.svg index efcfb38..145a08d 100644 --- a/docs/img/perdoo-commands.svg +++ b/docs/img/perdoo-commands.svg @@ -19,79 +19,79 @@ font-weight: 700; } - .terminal-242032079-matrix { + .terminal-2634423759-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-242032079-title { + .terminal-2634423759-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-242032079-r1 { fill: #c5c8c6;font-weight: bold } -.terminal-242032079-r2 { fill: #c5c8c6 } -.terminal-242032079-r3 { fill: #d0b344;font-weight: bold } -.terminal-242032079-r4 { fill: #868887 } -.terminal-242032079-r5 { fill: #68a0b3;font-weight: bold } + .terminal-2634423759-r1 { fill: #c5c8c6;font-weight: bold } +.terminal-2634423759-r2 { fill: #c5c8c6 } +.terminal-2634423759-r3 { fill: #d0b344;font-weight: bold } +.terminal-2634423759-r4 { fill: #868887 } +.terminal-2634423759-r5 { fill: #68a0b3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -103,27 +103,27 @@ - + - - -Usage: Perdoo [OPTIONS] COMMAND [ARGS]... - - CLI tool for managing comic collections and settings.                           - -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---version                     Show the version and exit.                      ---install-completion          Install completion for the current shell.       ---show-completion             Show completion for the current shell, to copy  -                               it or customize the installation.               ---help                        Show this message and exit.                     -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -import    Import comics into your collection using Perdoo.                   -archive   Commands for inspecting and managing comic archive metadata.       -settings  Commands for managing and configuring application settings.        -╰──────────────────────────────────────────────────────────────────────────────╯ - + + +Usage: Perdoo [OPTIONS] COMMAND [ARGS]... + + CLI tool for managing comic collections and settings.                           + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +--version                     Show the version and exit.                      +--install-completion          Install completion for the current shell.       +--show-completion             Show completion for the current shell, to copy  +                               it or customize the installation.               +--help                        Show this message and exit.                     +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +import   Import comics into your collection using Perdoo.                    +archive  Commands for inspecting and managing comic archive metadata.        +settings Commands for managing and configuring application settings.         +╰──────────────────────────────────────────────────────────────────────────────╯ + From 4007ea3b3864199b75ca17d988818be268fd28de Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:05:17 +1300 Subject: [PATCH 05/12] Remove ProcessingPlan --- perdoo/__main__.py | 171 +++++++++++++++++++++---------- perdoo/comic/archive/__init__.py | 3 +- perdoo/comic/archive/_base.py | 4 +- perdoo/comic/archive/session.py | 9 +- perdoo/comic/archive/tar.py | 9 +- perdoo/comic/comic.py | 17 +-- perdoo/processing.py | 97 ------------------ 7 files changed, 132 insertions(+), 178 deletions(-) delete mode 100644 perdoo/processing.py diff --git a/perdoo/__main__.py b/perdoo/__main__.py index a220c32..4021b45 100644 --- a/perdoo/__main__.py +++ b/perdoo/__main__.py @@ -11,13 +11,13 @@ from perdoo import __version__, get_cache_root, setup_logging from perdoo.cli import archive_app, settings_app from perdoo.comic import Comic +from perdoo.comic.archive import ArchiveSession from perdoo.comic.errors import ComicArchiveError, ComicMetadataError from perdoo.comic.metadata import ComicInfo, MetronInfo from perdoo.comic.metadata.metron_info import Id, InformationSource from perdoo.console import CONSOLE -from perdoo.processing import ProcessingPlan from perdoo.services import BaseService, Comicvine, Metron -from perdoo.settings import Service, Services, Settings +from perdoo.settings import Naming, Output, Service, Services, Settings from perdoo.utils import ( IssueSearch, Search, @@ -33,18 +33,11 @@ LOGGER = logging.getLogger("perdoo") -class SyncOption(Enum): +class SyncOption(str, Enum): FORCE = "Force" OUTDATED = "Outdated" SKIP = "Skip" - @staticmethod - def load(value: str) -> "SyncOption": - for entry in SyncOption: - if entry.value.casefold() == value.casefold(): - return entry - raise ValueError(f"'{value}' isn't a valid SyncOption") - @app.callback(invoke_without_command=True) def common( @@ -81,7 +74,7 @@ def setup_environment( recursive_delete(path=get_cache_root()) services = get_services(settings=settings.services) - if not services and sync != SyncOption.SKIP: + if not services and sync is not SyncOption.SKIP: LOGGER.warning("No external services configured") sync = SyncOption.SKIP return services, sync @@ -107,22 +100,22 @@ def prepare_comic(entry: Comic, settings: Settings, skip_convert: bool) -> bool: return True -def should_sync_metadata(sync: SyncOption, metroninfo: MetronInfo | None) -> bool: - if sync == SyncOption.SKIP: +def should_sync_metadata(sync: SyncOption, metron_info: MetronInfo | None) -> bool: + if sync is SyncOption.SKIP: return False - if sync == SyncOption.FORCE: + if sync is SyncOption.FORCE: return True - if metroninfo and metroninfo.last_modified: - age = (date.today() - metroninfo.last_modified.date()).days + if metron_info and metron_info.last_modified: + age = (date.today() - metron_info.last_modified.date()).days return age >= 28 return True -def _get_id_value(ids: list[Id], source: InformationSource) -> str | None: - return next((x.value for x in ids if x.source == source), None) +def get_id(ids: list[Id], source: InformationSource) -> str | None: + return next((x.value for x in ids if x.source is source), None) -def _create_search_from_metron_info(metron_info: MetronInfo) -> Search: +def search_from_metron_info(metron_info: MetronInfo) -> Search: series_id = metron_info.series.id source = next((x.source for x in metron_info.ids if x.primary), None) return Search( @@ -135,14 +128,14 @@ def _create_search_from_metron_info(metron_info: MetronInfo) -> Search: ), issue=IssueSearch( number=metron_info.number, - comicvine=_get_id_value(metron_info.ids, InformationSource.COMIC_VINE), - metron=_get_id_value(metron_info.ids, InformationSource.METRON), + comicvine=get_id(metron_info.ids, InformationSource.COMIC_VINE), + metron=get_id(metron_info.ids, InformationSource.METRON), ), ) -def _create_search_from_comic_info(comic_info: ComicInfo, filename: str) -> Search: - volume = comic_info.volume if comic_info.volume else None +def search_from_comic_info(comic_info: ComicInfo, filename: str) -> Search: + volume = comic_info.volume year = volume if volume and volume > 1900 else None volume = volume if volume and volume < 1900 else None return Search( @@ -151,26 +144,25 @@ def _create_search_from_comic_info(comic_info: ComicInfo, filename: str) -> Sear ) -def _create_search_from_filename(filename: str) -> Search: +def search_from_filename(filename: str) -> Search: series_name = comicfn2dict(filename).get("series", filename).replace("-", " ") return Search(series=SeriesSearch(name=series_name), issue=IssueSearch()) -def get_search_details( - metadata: tuple[MetronInfo | None, ComicInfo | None], filename: str +def build_search( + metron_info: MetronInfo | None, comic_info: ComicInfo | None, filename: str ) -> Search: - metron_info, comic_info = metadata if metron_info and metron_info.series and metron_info.series.name: - return _create_search_from_metron_info(metron_info=metron_info) + return search_from_metron_info(metron_info=metron_info) if comic_info and comic_info.series: - return _create_search_from_comic_info(comic_info=comic_info, filename=filename) - return _create_search_from_filename(filename=filename) + return search_from_comic_info(comic_info=comic_info, filename=filename) + return search_from_filename(filename=filename) def sync_metadata( - search: Search, services: dict[Service, BaseService | None], settings: Settings + search: Search, services: dict[Service, BaseService], service_order: tuple[Service, ...] ) -> tuple[MetronInfo | None, ComicInfo | None]: - for service_name in settings.services.order: + for service_name in service_order: if service := services.get(service_name): metron_info, comic_info = service.fetch(search=search) if metron_info or comic_info: @@ -179,14 +171,77 @@ def sync_metadata( def resolve_metadata( - entry: Comic, services: dict[Service, BaseService], settings: Settings, sync: SyncOption + entry: Comic, + session: ArchiveSession, + services: dict[Service, BaseService], + settings: Services, + sync: SyncOption, ) -> tuple[MetronInfo | None, ComicInfo | None]: - metroninfo, comicinfo = entry.read_metadata() - if not should_sync_metadata(sync=sync, metroninfo=metroninfo): - return metroninfo, comicinfo - search = get_search_details(metadata=(metroninfo, comicinfo), filename=entry.filepath.stem) + metron_info, comic_info = entry.read_metadata(session=session) + if not should_sync_metadata(sync=sync, metron_info=metron_info): + return metron_info, comic_info + search = build_search( + metron_info=metron_info, comic_info=comic_info, filename=entry.filepath.stem + ) search.filename = entry.filepath.stem - return sync_metadata(search=search, services=services, settings=settings) + return sync_metadata(search=search, services=services, service_order=settings.order) + + +def generate_naming( + settings: Naming, metron_info: MetronInfo | None, comic_info: ComicInfo | None +) -> str | None: + filepath = None + if metron_info: + filepath = metron_info.get_filename(settings=settings) + if not filepath and comic_info: + filepath = comic_info.get_filename(settings=settings) + return filepath.lstrip("/") if filepath else None + + +def apply_changes( + entry: Comic, + session: ArchiveSession, + metron_info: MetronInfo | None, + comic_info: ComicInfo | None, + skip_clean: bool, + skip_rename: bool, + settings: Output, +) -> str | None: + local_metron_info, local_comic_info = entry.read_metadata(session=session) + if local_metron_info != metron_info: + if metron_info: + session.write(filename=MetronInfo.FILENAME, data=metron_info.to_bytes()) + else: + session.remove(filename=MetronInfo.FILENAME) + session.updated = True + + if local_comic_info != comic_info: + if comic_info: + session.write(filename=ComicInfo.FILENAME, data=comic_info.to_bytes()) + else: + session.remove(filename=ComicInfo.FILENAME) + session.updated = True + + if not skip_clean: + for extra in entry.list_extras(): + session.remove(filename=extra.name) + session.updated = True + + naming = None + if not skip_rename and ( + naming := generate_naming( + settings=settings.naming, metron_info=metron_info, comic_info=comic_info + ) + ): + images = entry.list_images() + stem = Path(naming).stem + pad = len(str(len(images))) + for idx, img in enumerate(images): + new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" + if img.name != new_name: + session.rename(old_name=img.name, new_name=new_name) + session.updated = True + return naming @app.command(name="import", help="Import comics into your collection using Perdoo.") @@ -244,29 +299,33 @@ def run( ) comics = load_comics(target=target) - for index, entry in enumerate(comics): + total = len(comics) + for index, entry in enumerate(comics, start=1): CONSOLE.rule( - f"[{index + 1}/{len(comics)}] Importing {entry.filepath.name}", - align="left", - style="subtitle", + f"[{index}/{total}] Importing {entry.filepath.name}", align="left", style="subtitle" ) if not prepare_comic(entry=entry, settings=settings, skip_convert=skip_convert): continue - metroninfo, comicinfo = resolve_metadata( - entry=entry, services=services, settings=settings, sync=sync - ) - plan = ProcessingPlan.build( - entry=entry, - metroninfo=metroninfo, - comicinfo=comicinfo, - settings=settings.output, - skip_clean=skip_clean, - skip_rename=skip_rename, - ) - plan.apply() - if plan.naming: - entry.move_to(naming=plan.naming, output_folder=settings.output.folder) + with entry.open_session() as session: + metron_info, comic_info = resolve_metadata( + entry=entry, + session=session, + services=services, + settings=settings.services, + sync=sync, + ) + naming = apply_changes( + entry=entry, + session=session, + metron_info=metron_info, + comic_info=comic_info, + skip_clean=skip_clean, + skip_rename=skip_rename, + settings=settings.output, + ) + if naming: + entry.move_to(naming=naming, output_folder=settings.output.folder) with CONSOLE.status("Cleaning up empty folders"): delete_empty_folders(folder=target) diff --git a/perdoo/comic/archive/__init__.py b/perdoo/comic/archive/__init__.py index 53f7b87..50a1b70 100644 --- a/perdoo/comic/archive/__init__.py +++ b/perdoo/comic/archive/__init__.py @@ -1,7 +1,8 @@ -__all__ = ["Archive", "CB7Archive", "CBRArchive", "CBTArchive", "CBZArchive"] +__all__ = ["Archive", "ArchiveSession", "CB7Archive", "CBRArchive", "CBTArchive", "CBZArchive"] from perdoo.comic.archive._base import Archive from perdoo.comic.archive.rar import CBRArchive +from perdoo.comic.archive.session import ArchiveSession 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 index 04d82a4..c7ddb5d 100644 --- a/perdoo/comic/archive/_base.py +++ b/perdoo/comic/archive/_base.py @@ -44,8 +44,8 @@ def is_archive(cls, path: Path) -> bool: ... @abstractmethod def list_filenames(self) -> list[str]: ... - @abstractmethod - def read_file(self, filename: str) -> bytes: ... + def read_file(self, filename: str) -> bytes: + raise ComicArchiveError(f"Unable to read {filename} from {self.filepath.name}.") def write_file(self, filename: str, data: str | bytes) -> None: # noqa: ARG002 raise ComicArchiveError(f"Unable to write {filename} to {self.filepath.name}.") diff --git a/perdoo/comic/archive/session.py b/perdoo/comic/archive/session.py index 81d24e7..94a469c 100644 --- a/perdoo/comic/archive/session.py +++ b/perdoo/comic/archive/session.py @@ -25,6 +25,7 @@ def __init__(self, archive: Archive) -> None: self._temp_dir: TemporaryDirectory | None = None self._folder: Path | None = None self._extracted = False + self.updated = False def __enter__(self) -> Self: if self._archive.IS_EDITABLE: @@ -37,6 +38,7 @@ def __enter__(self) -> Self: ): self._archive.extract_files(destination=self._folder) self._extracted = True + self.updated = False return self def __exit__( @@ -46,7 +48,7 @@ def __exit__( tb: TracebackType | None, ) -> None: try: - if exc_type is None and self._extracted: + if exc_type is None and self._extracted and self.updated: with CONSOLE.status( f"Archiving {self._folder} to {self._archive.filepath}", spinner="simpleDotsScrolling", @@ -69,8 +71,11 @@ def list(self) -> list[str]: return self._archive.list_filenames() return [p.name for p in self._folder.iterdir()] + def contains(self, filename: str) -> bool: + return filename in self.list() + def read(self, filename: str) -> bytes: - if self._archive.IS_EDITABLE: + if self._archive.IS_READABLE: return self._archive.read_file(filename) return (self._folder / filename).read_bytes() diff --git a/perdoo/comic/archive/tar.py b/perdoo/comic/archive/tar.py index ab98c8c..f7d0442 100644 --- a/perdoo/comic/archive/tar.py +++ b/perdoo/comic/archive/tar.py @@ -21,7 +21,7 @@ class CBTArchive(Archive): EXTENSION: ClassVar[str] = ".cbt" - IS_READABLE: ClassVar[bool] = True + IS_READABLE: ClassVar[bool] = False IS_WRITEABLE: ClassVar[bool] = True IS_EDITABLE: ClassVar[bool] = False @@ -38,13 +38,6 @@ def list_filenames(self) -> list[str]: 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: diff --git a/perdoo/comic/comic.py b/perdoo/comic/comic.py index 1eb6ce0..95ee4e3 100644 --- a/perdoo/comic/comic.py +++ b/perdoo/comic/comic.py @@ -35,21 +35,14 @@ def convert_to(self, extension: Literal["cbz", "cbt", "cb7"]) -> None: if not isinstance(self.archive, cls): self._archive = cls.convert_from(old_archive=self.archive) - def contains(self, filename: str) -> bool: - return filename in self.archive.list_filenames() - - def read_metadata(self) -> tuple[MetronInfo | None, ComicInfo | None]: + def read_metadata(self, session: ArchiveSession) -> tuple[MetronInfo | None, ComicInfo | None]: metroninfo = None - if self.contains(filename=MetronInfo.FILENAME): - metroninfo = MetronInfo.from_bytes( - content=self.archive.read_file(filename=MetronInfo.FILENAME) - ) + if session.contains(filename=MetronInfo.FILENAME): + metroninfo = MetronInfo.from_bytes(content=session.read(filename=MetronInfo.FILENAME)) comicinfo = None - if self.contains(filename=ComicInfo.FILENAME): - comicinfo = ComicInfo.from_bytes( - content=self.archive.read_file(filename=ComicInfo.FILENAME) - ) + if session.contains(filename=ComicInfo.FILENAME): + comicinfo = ComicInfo.from_bytes(content=session.read(filename=ComicInfo.FILENAME)) return metroninfo, comicinfo diff --git a/perdoo/processing.py b/perdoo/processing.py deleted file mode 100644 index 710c6c0..0000000 --- a/perdoo/processing.py +++ /dev/null @@ -1,97 +0,0 @@ -__all__ = ["ProcessingPlan"] - -from dataclasses import dataclass -from pathlib import Path - -from perdoo.comic import Comic -from perdoo.comic.metadata import ComicInfo, MetronInfo -from perdoo.settings import Naming, Output - -try: - from typing import Self # Python >= 3.11 -except ImportError: - from typing_extensions import Self # Python < 3.11 - - -def generate_naming( - settings: Naming, metadata: tuple[MetronInfo | None, ComicInfo | None] -) -> str | None: - filepath = None - if metadata[0]: - filepath = metadata[0].get_filename(settings=settings) - if not filepath and metadata[1]: - filepath = metadata[1].get_filename(settings=settings) - return filepath.lstrip("/") if filepath else None - - -@dataclass -class ProcessingPlan: - comic: Comic - metroninfo: MetronInfo | None - comicinfo: ComicInfo | None - write_metron: bool - write_comic: bool - remove_extras: list[Path] - rename_images: bool - naming: str | None - - @classmethod - def build( - cls, - entry: Comic, - metroninfo: MetronInfo | None, - comicinfo: ComicInfo | None, - settings: Output, - skip_clean: bool, - skip_rename: bool, - ) -> Self: - local_metron, local_comic = entry.read_metadata() - write_metron = local_metron != metroninfo - write_comic = local_comic != comicinfo - - extras = entry.list_extras() if not skip_clean else [] - - naming = None - rename_images = False - if not skip_rename: - naming = generate_naming(settings.naming, (metroninfo, comicinfo)) - rename_images = bool(naming and not entry.validate_naming(naming)) - return cls( - comic=entry, - metroninfo=metroninfo, - comicinfo=comicinfo, - write_metron=write_metron, - write_comic=write_comic, - remove_extras=extras, - rename_images=rename_images, - naming=naming, - ) - - def apply(self) -> None: - if not (self.write_metron or self.write_comic or self.remove_extras or self.rename_images): - return - - with self.comic.open_session() as session: - if self.write_metron: - if self.metroninfo: - session.write(MetronInfo.FILENAME, self.metroninfo.to_bytes()) - else: - session.remove(MetronInfo.FILENAME) - - if self.write_comic: - if self.comicinfo: - session.write(ComicInfo.FILENAME, self.comicinfo.to_bytes()) - else: - session.remove(ComicInfo.FILENAME) - - for extra in self.remove_extras: - session.remove(extra.name) - - if self.rename_images and self.naming: - images = self.comic.list_images() - stem = Path(self.naming).stem - pad = len(str(len(images))) - for idx, img in enumerate(images): - new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" - if img.name != new_name: - session.rename(img.name, new_name) From 8641a2b8ef2f9be225f65c3a00ba70040efc1fbb Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:37:48 +1300 Subject: [PATCH 06/12] Fix file sorting --- .pre-commit-config.yaml | 2 +- perdoo/comic/archive/session.py | 10 ++++++---- perdoo/comic/comic.py | 29 +++++++++++++++++++---------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 80ba77f..b3888d7 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.13 + rev: v0.14.14 hooks: - id: ruff-format - id: ruff-check diff --git a/perdoo/comic/archive/session.py b/perdoo/comic/archive/session.py index 94a469c..ef5d6be 100644 --- a/perdoo/comic/archive/session.py +++ b/perdoo/comic/archive/session.py @@ -34,7 +34,8 @@ def __enter__(self) -> Self: self._temp_dir = TemporaryDirectory() self._folder = Path(self._temp_dir.name) with CONSOLE.status( - f"Extracting {self._archive.filepath} to {self._folder}", spinner="simpleDotsScrolling" + f"Extracting '{self._archive.filepath}' to '{self._folder}'", + spinner="simpleDotsScrolling", ): self._archive.extract_files(destination=self._folder) self._extracted = True @@ -50,7 +51,7 @@ def __exit__( try: if exc_type is None and self._extracted and self.updated: with CONSOLE.status( - f"Archiving {self._folder} to {self._archive.filepath}", + f"Archiving '{self._folder}' to '{self._archive.filepath}'", spinner="simpleDotsScrolling", ): filepath = self._archive.archive_files( @@ -80,20 +81,21 @@ def read(self, filename: str) -> bytes: return (self._folder / filename).read_bytes() def write(self, filename: str, data: bytes) -> None: + LOGGER.info("Writing '%s'", filename) if self._archive.IS_EDITABLE: self._archive.write_file(filename, data) else: (self._folder / filename).write_bytes(data) def remove(self, filename: str) -> None: - LOGGER.info("Removing %s", filename) + LOGGER.info("Removing '%s'", filename) if self._archive.IS_EDITABLE: self._archive.remove_file(filename) else: (self._folder / filename).unlink(missing_ok=True) def rename(self, old_name: str, new_name: str) -> None: - LOGGER.info("Renaming %s to %s", old_name, new_name) + LOGGER.info("Renaming '%s' to '%s'", old_name, new_name) if self._archive.IS_EDITABLE: self._archive.rename_file(old_name=old_name, new_name=new_name) else: diff --git a/perdoo/comic/comic.py b/perdoo/comic/comic.py index 95ee4e3..87ece9c 100644 --- a/perdoo/comic/comic.py +++ b/perdoo/comic/comic.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Final, Literal +from natsort import humansorted, ns + from perdoo.comic.archive import Archive, CB7Archive, CBTArchive, CBZArchive from perdoo.comic.archive.session import ArchiveSession from perdoo.comic.metadata import ComicInfo, MetronInfo @@ -47,18 +49,25 @@ def read_metadata(self, session: ArchiveSession) -> tuple[MetronInfo | None, Com return metroninfo, comicinfo def list_images(self) -> list[Path]: - return [ - Path(name) - for name in self.archive.list_filenames() - if Path(name).suffix.lower() in IMAGE_EXTENSIONS - ] + return humansorted( + [ + Path(name) + for name in self.archive.list_filenames() + if Path(name).suffix.lower() in IMAGE_EXTENSIONS + ], + alg=ns.NA | ns.G | ns.P, + ) def list_extras(self) -> list[Path]: - return [ - Path(name) - for name in self.archive.list_filenames() - if name not in METADATA_FILENAMES and Path(name).suffix.lower() not in IMAGE_EXTENSIONS - ] + return humansorted( + [ + Path(name) + for name in self.archive.list_filenames() + if name not in METADATA_FILENAMES + and Path(name).suffix.lower() not in IMAGE_EXTENSIONS + ], + alg=ns.NA | ns.G | ns.P, + ) def validate_naming(self, naming: str) -> bool: template = Path(naming).stem From 03f4e66510fb76277cb758c81b0e0b35ab63e57a Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:34:32 +1300 Subject: [PATCH 07/12] Tidy things up --- perdoo/__main__.py | 14 ++---- perdoo/comic/archive/__init__.py | 8 --- perdoo/comic/archives/__init__.py | 8 +++ perdoo/comic/{archive => archives}/_base.py | 4 +- perdoo/comic/{archive => archives}/rar.py | 2 +- perdoo/comic/{archive => archives}/session.py | 42 +++++++++------- .../comic/{archive => archives}/sevenzip.py | 45 +++++++++++++++-- perdoo/comic/{archive => archives}/tar.py | 4 +- perdoo/comic/{archive => archives}/zip.py | 24 ++++----- perdoo/comic/comic.py | 3 +- pyproject.toml | 9 ++-- uv.lock | 50 +++++++++---------- 12 files changed, 124 insertions(+), 89 deletions(-) delete mode 100644 perdoo/comic/archive/__init__.py create mode 100644 perdoo/comic/archives/__init__.py rename perdoo/comic/{archive => archives}/_base.py (94%) rename perdoo/comic/{archive => archives}/rar.py (96%) rename perdoo/comic/{archive => archives}/session.py (67%) rename perdoo/comic/{archive => archives}/sevenzip.py (57%) rename perdoo/comic/{archive => archives}/tar.py (95%) rename perdoo/comic/{archive => archives}/zip.py (86%) diff --git a/perdoo/__main__.py b/perdoo/__main__.py index 4021b45..c06155e 100644 --- a/perdoo/__main__.py +++ b/perdoo/__main__.py @@ -11,7 +11,7 @@ from perdoo import __version__, get_cache_root, setup_logging from perdoo.cli import archive_app, settings_app from perdoo.comic import Comic -from perdoo.comic.archive import ArchiveSession +from perdoo.comic.archives import ArchiveSession from perdoo.comic.errors import ComicArchiveError, ComicMetadataError from perdoo.comic.metadata import ComicInfo, MetronInfo from perdoo.comic.metadata.metron_info import Id, InformationSource @@ -212,20 +212,17 @@ def apply_changes( if metron_info: session.write(filename=MetronInfo.FILENAME, data=metron_info.to_bytes()) else: - session.remove(filename=MetronInfo.FILENAME) - session.updated = True + session.delete(filename=MetronInfo.FILENAME) if local_comic_info != comic_info: if comic_info: session.write(filename=ComicInfo.FILENAME, data=comic_info.to_bytes()) else: - session.remove(filename=ComicInfo.FILENAME) - session.updated = True + session.delete(filename=ComicInfo.FILENAME) if not skip_clean: for extra in entry.list_extras(): - session.remove(filename=extra.name) - session.updated = True + session.delete(filename=extra.name) naming = None if not skip_rename and ( @@ -239,8 +236,7 @@ def apply_changes( for idx, img in enumerate(images): new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" if img.name != new_name: - session.rename(old_name=img.name, new_name=new_name) - session.updated = True + session.rename(filename=img.name, new_name=new_name) return naming diff --git a/perdoo/comic/archive/__init__.py b/perdoo/comic/archive/__init__.py deleted file mode 100644 index 50a1b70..0000000 --- a/perdoo/comic/archive/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -__all__ = ["Archive", "ArchiveSession", "CB7Archive", "CBRArchive", "CBTArchive", "CBZArchive"] - -from perdoo.comic.archive._base import Archive -from perdoo.comic.archive.rar import CBRArchive -from perdoo.comic.archive.session import ArchiveSession -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/archives/__init__.py b/perdoo/comic/archives/__init__.py new file mode 100644 index 0000000..749b39f --- /dev/null +++ b/perdoo/comic/archives/__init__.py @@ -0,0 +1,8 @@ +__all__ = ["Archive", "ArchiveSession", "CB7Archive", "CBRArchive", "CBTArchive", "CBZArchive"] + +from perdoo.comic.archives._base import Archive +from perdoo.comic.archives.rar import CBRArchive +from perdoo.comic.archives.session import ArchiveSession +from perdoo.comic.archives.sevenzip import CB7Archive +from perdoo.comic.archives.tar import CBTArchive +from perdoo.comic.archives.zip import CBZArchive diff --git a/perdoo/comic/archive/_base.py b/perdoo/comic/archives/_base.py similarity index 94% rename from perdoo/comic/archive/_base.py rename to perdoo/comic/archives/_base.py index c7ddb5d..9a65f93 100644 --- a/perdoo/comic/archive/_base.py +++ b/perdoo/comic/archives/_base.py @@ -47,10 +47,10 @@ def list_filenames(self) -> list[str]: ... def read_file(self, filename: str) -> bytes: raise ComicArchiveError(f"Unable to read {filename} from {self.filepath.name}.") - def write_file(self, filename: str, data: str | bytes) -> None: # noqa: ARG002 + def write_file(self, filename: str, data: bytes) -> None: # noqa: ARG002 raise ComicArchiveError(f"Unable to write {filename} to {self.filepath.name}.") - def remove_file(self, filename: str) -> None: + def delete_file(self, filename: str) -> None: raise ComicArchiveError(f"Unable to delete {filename} in {self.filepath.name}.") def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002 diff --git a/perdoo/comic/archive/rar.py b/perdoo/comic/archives/rar.py similarity index 96% rename from perdoo/comic/archive/rar.py rename to perdoo/comic/archives/rar.py index bd47802..d8c8e2a 100644 --- a/perdoo/comic/archive/rar.py +++ b/perdoo/comic/archives/rar.py @@ -6,7 +6,7 @@ from rarfile import RarFile, is_rarfile -from perdoo.comic.archive._base import Archive +from perdoo.comic.archives._base import Archive from perdoo.comic.errors import ComicArchiveError LOGGER = logging.getLogger(__name__) diff --git a/perdoo/comic/archive/session.py b/perdoo/comic/archives/session.py similarity index 67% rename from perdoo/comic/archive/session.py rename to perdoo/comic/archives/session.py index ef5d6be..c2934c2 100644 --- a/perdoo/comic/archive/session.py +++ b/perdoo/comic/archives/session.py @@ -6,7 +6,7 @@ from tempfile import TemporaryDirectory from types import TracebackType -from perdoo.comic.archive import Archive +from perdoo.comic.archives import Archive from perdoo.comic.errors import ComicArchiveError from perdoo.console import CONSOLE from perdoo.utils import list_files @@ -25,7 +25,7 @@ def __init__(self, archive: Archive) -> None: self._temp_dir: TemporaryDirectory | None = None self._folder: Path | None = None self._extracted = False - self.updated = False + self._updated = False def __enter__(self) -> Self: if self._archive.IS_EDITABLE: @@ -39,7 +39,7 @@ def __enter__(self) -> Self: ): self._archive.extract_files(destination=self._folder) self._extracted = True - self.updated = False + self._updated = False return self def __exit__( @@ -49,7 +49,7 @@ def __exit__( tb: TracebackType | None, ) -> None: try: - if exc_type is None and self._extracted and self.updated: + if exc_type is None and self._extracted and self._updated: with CONSOLE.status( f"Archiving '{self._folder}' to '{self._archive.filepath}'", spinner="simpleDotsScrolling", @@ -57,7 +57,7 @@ def __exit__( filepath = self._archive.archive_files( src=self._folder, output_name=self._archive.filepath.stem, - files=list_files(self._folder), + files=list_files(path=self._folder), ) self._archive.filepath.unlink(missing_ok=True) shutil.move(filepath, self._archive.filepath) @@ -77,29 +77,37 @@ def contains(self, filename: str) -> bool: def read(self, filename: str) -> bytes: if self._archive.IS_READABLE: - return self._archive.read_file(filename) + return self._archive.read_file(filename=filename) return (self._folder / filename).read_bytes() - def write(self, filename: str, data: bytes) -> None: + def write(self, filename: str, data: str | bytes) -> None: LOGGER.info("Writing '%s'", filename) + if isinstance(data, str): + data = data.encode("UTF-8") if self._archive.IS_EDITABLE: - self._archive.write_file(filename, data) + self._archive.write_file(filename=filename, data=data) else: (self._folder / filename).write_bytes(data) + self._updated = True - def remove(self, filename: str) -> None: - LOGGER.info("Removing '%s'", filename) + def delete(self, filename: str) -> None: + LOGGER.info("Deleting '%s'", filename) if self._archive.IS_EDITABLE: - self._archive.remove_file(filename) + self._archive.delete_file(filename=filename) else: (self._folder / filename).unlink(missing_ok=True) + self._updated = True - def rename(self, old_name: str, new_name: str) -> None: - LOGGER.info("Renaming '%s' to '%s'", old_name, new_name) + def rename(self, filename: str, new_name: str, override: bool = False) -> None: + LOGGER.info("Renaming '%s' to '%s'", filename, new_name) if self._archive.IS_EDITABLE: - self._archive.rename_file(old_name=old_name, new_name=new_name) + self._archive.rename_file(filename=filename, new_name=new_name, override=override) else: - src = self._folder / old_name + src = self._folder / filename if not src.exists(): - raise ComicArchiveError(f"{old_name} does not exist") - src.rename(self._folder / new_name) + raise ComicArchiveError(f"Unable to rename '{src}' as it does not exist.") + dest = self._folder / new_name + if dest.exists() and not override: + raise ComicArchiveError(f"Unable to rename '{src}' as '{dest}' already exists.") + shutil.move(src, dest) + self._updated = True diff --git a/perdoo/comic/archive/sevenzip.py b/perdoo/comic/archives/sevenzip.py similarity index 57% rename from perdoo/comic/archive/sevenzip.py rename to perdoo/comic/archives/sevenzip.py index 4d4a939..1e92480 100644 --- a/perdoo/comic/archive/sevenzip.py +++ b/perdoo/comic/archives/sevenzip.py @@ -1,10 +1,16 @@ -__all__ = ["CB7Archive"] +__all__ = ["PY7ZR_AVAILABLE", "CB7Archive"] import logging +import shutil from pathlib import Path from sys import maxsize +from tempfile import TemporaryDirectory from typing import ClassVar +from perdoo.comic.archive._base import Archive +from perdoo.comic.errors import ComicArchiveError +from perdoo.utils import list_files + try: import py7zr @@ -13,8 +19,11 @@ py7zr = None PY7ZR_AVAILABLE = False -from perdoo.comic.archive._base import Archive -from perdoo.comic.errors import ComicArchiveError +try: + from typing import Self # Python >= 3.11 +except ImportError: + from typing_extensions import Self # Python < 3.11 + LOGGER = logging.getLogger(__name__) @@ -22,7 +31,7 @@ class CB7Archive(Archive): EXTENSION: ClassVar[str] = ".cb7" IS_READABLE: ClassVar[bool] = True - IS_WRITEABLE: ClassVar[bool] = False + IS_WRITEABLE: ClassVar[bool] = True IS_EDITABLE: ClassVar[bool] = False @classmethod @@ -47,7 +56,7 @@ def read_file(self, filename: str) -> bytes: 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 + raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}") except ComicArchiveError: raise except Exception as err: @@ -61,3 +70,29 @@ def extract_files(self, destination: Path) -> None: 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]) -> Path: + output_file = src.parent / (output_name + cls.EXTENSION) + try: + with py7zr.SevenZipFile(output_file, "w") as archive: + for file in files: + archive.write(file, arcname=file.name) + return 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) + filepath = cls.archive_files( + src=temp_folder, + output_name=old_archive.filepath.stem, + files=list_files(temp_folder), + ) + new_filepath = old_archive.filepath.with_suffix(cls.EXTENSION) + old_archive.filepath.unlink(missing_ok=True) + shutil.move(filepath, new_filepath) + return cls(filepath=new_filepath) diff --git a/perdoo/comic/archive/tar.py b/perdoo/comic/archives/tar.py similarity index 95% rename from perdoo/comic/archive/tar.py rename to perdoo/comic/archives/tar.py index f7d0442..ce6f146 100644 --- a/perdoo/comic/archive/tar.py +++ b/perdoo/comic/archives/tar.py @@ -7,7 +7,7 @@ from tempfile import TemporaryDirectory from typing import ClassVar -from perdoo.comic.archive._base import Archive +from perdoo.comic.archives._base import Archive from perdoo.comic.errors import ComicArchiveError from perdoo.utils import list_files @@ -47,7 +47,7 @@ def extract_files(self, destination: Path) -> None: @classmethod def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: - output_file = src.parent / f"{output_name}.cbt" + output_file = src.parent / (output_name + cls.EXTENSION) try: with tarfile.open(name=output_file, mode="w:gz") as archive: for file in files: diff --git a/perdoo/comic/archive/zip.py b/perdoo/comic/archives/zip.py similarity index 86% rename from perdoo/comic/archive/zip.py rename to perdoo/comic/archives/zip.py index e1fd574..919009c 100644 --- a/perdoo/comic/archive/zip.py +++ b/perdoo/comic/archives/zip.py @@ -8,7 +8,7 @@ from zipremove import ZIP_DEFLATED, ZipFile, is_zipfile -from perdoo.comic.archive._base import Archive +from perdoo.comic.archives._base import Archive from perdoo.comic.errors import ComicArchiveError from perdoo.utils import list_files @@ -49,9 +49,7 @@ def read_file(self, filename: str) -> bytes: 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") + def write_file(self, filename: str, data: bytes) -> None: try: with ZipFile(file=self.filepath, mode="a") as archive: if filename in archive.namelist(): @@ -61,7 +59,7 @@ def write_file(self, filename: str, data: str | bytes) -> None: except Exception as err: raise ComicArchiveError(f"Unable to write {filename} to {self.filepath.name}") from err - def remove_file(self, filename: str) -> None: + def delete_file(self, filename: str) -> None: if filename not in self.list_filenames(): return try: @@ -73,27 +71,27 @@ def remove_file(self, filename: str) -> None: f"Unable to delete {filename} from {self.filepath.name}" ) from err - def rename_file(self, old_name: str, new_name: str, override: bool = False) -> None: - if old_name not in self.list_filenames(): + def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: + if filename not in self.list_filenames(): raise ComicArchiveError( - f"Unable to rename {old_name} as it doesn't exist in {self.filepath.name}." + f"Unable to rename {filename} as it doesn't exist in {self.filepath.name}." ) try: removed = [] with ZipFile(file=self.filepath, mode="a") as archive: if new_name in archive.namelist(): if not override: - raise ComicArchiveError( # noqa: TRY301 - f"Unable to rename {old_name} as {new_name} already extsts in {self.filepath.name}." + raise ComicArchiveError( + f"Unable to rename {filename} as {new_name} already extsts in {self.filepath.name}." ) removed.append(archive.remove(new_name)) - removed.append(archive.remove(archive.copy(old_name, new_name))) + removed.append(archive.remove(archive.copy(filename, new_name))) archive.repack(removed) except ComicArchiveError: raise except Exception as err: raise ComicArchiveError( - f"Unable to rename {old_name} to {new_name} in {self.filepath.name}" + f"Unable to rename {filename} to {new_name} in {self.filepath.name}" ) from err def extract_files(self, destination: Path) -> None: @@ -107,7 +105,7 @@ def extract_files(self, destination: Path) -> None: @classmethod def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: - output_file = src.parent / f"{output_name}.cbz" + output_file = src.parent / (output_name + cls.EXTENSION) try: with ZipFile(file=output_file, mode="w", compression=ZIP_DEFLATED) as archive: for file in files: diff --git a/perdoo/comic/comic.py b/perdoo/comic/comic.py index 87ece9c..3093880 100644 --- a/perdoo/comic/comic.py +++ b/perdoo/comic/comic.py @@ -7,8 +7,7 @@ from natsort import humansorted, ns -from perdoo.comic.archive import Archive, CB7Archive, CBTArchive, CBZArchive -from perdoo.comic.archive.session import ArchiveSession +from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive from perdoo.comic.metadata import ComicInfo, MetronInfo LOGGER = logging.getLogger(__name__) diff --git a/pyproject.toml b/pyproject.toml index e81accd..7ae38e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,17 +103,16 @@ ignore = [ "COM812", "D", "DTZ", - "EM101", - "EM102", + "EM", "FBT", + "FIX", "PLR0912", "PLR0913", "PLR2004", "PLW2901", "TCH", - "TRY003", - "TRY300", - "TRY400" + "TD", + "TRY" ] select = ["ALL"] diff --git a/uv.lock b/uv.lock index b67588f..533283d 100644 --- a/uv.lock +++ b/uv.lock @@ -1797,28 +1797,28 @@ wheels = [ [[package]] name = "uv" -version = "0.9.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/6a/ef4ea19097ecdfd7df6e608f93874536af045c68fd70aa628c667815c458/uv-0.9.26.tar.gz", hash = "sha256:8b7017a01cc48847a7ae26733383a2456dd060fc50d21d58de5ee14f6b6984d7", size = 3790483, upload-time = "2026-01-15T20:51:33.582Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/e1/5c0b17833d5e3b51a897957348ff8d937a3cdfc5eea5c4a7075d8d7b9870/uv-0.9.26-py3-none-linux_armv6l.whl", hash = "sha256:7dba609e32b7bd13ef81788d580970c6ff3a8874d942755b442cffa8f25dba57", size = 22638031, upload-time = "2026-01-15T20:51:44.187Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8b/68ac5825a615a8697e324f52ac0b92feb47a0ec36a63759c5f2931f0c3a0/uv-0.9.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b815e3b26eeed00e00f831343daba7a9d99c1506883c189453bb4d215f54faac", size = 21507805, upload-time = "2026-01-15T20:50:42.574Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a2/664a338aefe009f6e38e47455ee2f64a21da7ad431dbcaf8b45d8b1a2b7a/uv-0.9.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b012e6c4dfe767f818cbb6f47d02c207c9b0c82fee69a5de6d26ffb26a3ef3c", size = 20249791, upload-time = "2026-01-15T20:50:49.835Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3d/b8186a7dec1346ca4630c674b760517d28bffa813a01965f4b57596bacf3/uv-0.9.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ea296b700d7c4c27acdfd23ffaef2b0ecdd0aa1b58d942c62ee87df3b30f06ac", size = 22039108, upload-time = "2026-01-15T20:51:00.675Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a9/687fd587e7a3c2c826afe72214fb24b7f07b0d8b0b0300e6a53b554180ea/uv-0.9.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1ba860d2988efc27e9c19f8537a2f9fa499a8b7ebe4afbe2d3d323d72f9aee61", size = 22174763, upload-time = "2026-01-15T20:50:46.471Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/7fa03ee7d59e562fca1426436f15a8c107447d41b34e0899e25ee69abfad/uv-0.9.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8610bdfc282a681a0a40b90495a478599aa3484c12503ef79ef42cd271fd80fe", size = 22189861, upload-time = "2026-01-15T20:51:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/10/2d/4be446a2ec09f3c428632b00a138750af47c76b0b9f987e9a5b52fef0405/uv-0.9.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4bf700bd071bd595084b9ee0a8d77c6a0a10ca3773d3771346a2599f306bd9c", size = 23005589, upload-time = "2026-01-15T20:50:57.185Z" }, - { url = "https://files.pythonhosted.org/packages/c3/16/860990b812136695a63a8da9fb5f819c3cf18ea37dcf5852e0e1b795ca0d/uv-0.9.26-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:89a7beea1c692f76a6f8da13beff3cbb43f7123609e48e03517cc0db5c5de87c", size = 24713505, upload-time = "2026-01-15T20:51:04.366Z" }, - { url = "https://files.pythonhosted.org/packages/01/43/5d7f360d551e62d8f8bf6624b8fca9895cea49ebe5fce8891232d7ed2321/uv-0.9.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:182f5c086c7d03ad447e522b70fa29a0302a70bcfefad4b8cd08496828a0e179", size = 24342500, upload-time = "2026-01-15T20:51:47.863Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9c/2bae010a189e7d8e5dc555edcfd053b11ce96fad2301b919ba0d9dd23659/uv-0.9.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d8c62a501f13425b4b0ce1dd4c6b82f3ce5a5179e2549c55f4bb27cc0eb8ef8", size = 23222578, upload-time = "2026-01-15T20:51:36.85Z" }, - { url = "https://files.pythonhosted.org/packages/38/16/a07593a040fe6403c36f3b0a99b309f295cbfe19a1074dbadb671d5d4ef7/uv-0.9.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7e89798bd3df7dcc4b2b4ac4e2fc11d6b3ff4fe7d764aa3012d664c635e2922", size = 23250201, upload-time = "2026-01-15T20:51:19.117Z" }, - { url = "https://files.pythonhosted.org/packages/23/a0/45893e15ad3ab842db27c1eb3b8605b9b4023baa5d414e67cfa559a0bff0/uv-0.9.26-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60a66f1783ec4efc87b7e1f9bd66e8fd2de3e3b30d122b31cb1487f63a3ea8b7", size = 22229160, upload-time = "2026-01-15T20:51:22.931Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c0/20a597a5c253702a223b5e745cf8c16cd5dd053080f896bb10717b3bedec/uv-0.9.26-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:63c6a1f1187facba1fb45a2fa45396980631a3427ac11b0e3d9aa3ebcf2c73cf", size = 23090730, upload-time = "2026-01-15T20:51:26.611Z" }, - { url = "https://files.pythonhosted.org/packages/40/c9/744537867d9ab593fea108638b57cca1165a0889cfd989981c942b6de9a5/uv-0.9.26-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c6d8650fbc980ccb348b168266143a9bd4deebc86437537caaf8ff2a39b6ea50", size = 22436632, upload-time = "2026-01-15T20:51:12.045Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e2/be683e30262f2cf02dcb41b6c32910a6939517d50ec45f502614d239feb7/uv-0.9.26-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:25278f9298aa4dade38241a93d036739b0c87278dcfad1ec1f57e803536bfc49", size = 23480064, upload-time = "2026-01-15T20:50:53.333Z" }, - { url = "https://files.pythonhosted.org/packages/50/3e/4a7e6bc5db2beac9c4966f212805f1903d37d233f2e160737f0b24780ada/uv-0.9.26-py3-none-win32.whl", hash = "sha256:10d075e0193e3a0e6c54f830731c4cb965d6f4e11956e84a7bed7ed61d42aa27", size = 21000052, upload-time = "2026-01-15T20:51:40.753Z" }, - { url = "https://files.pythonhosted.org/packages/07/5d/eb80c6eff2a9f7d5cf35ec84fda323b74aa0054145db28baf72d35a7a301/uv-0.9.26-py3-none-win_amd64.whl", hash = "sha256:0315fc321f5644b12118f9928086513363ed9b29d74d99f1539fda1b6b5478ab", size = 23684930, upload-time = "2026-01-15T20:51:08.448Z" }, - { url = "https://files.pythonhosted.org/packages/ed/9d/3b2631931649b1783f5024796ca8ad2b42a01a829b9ce1202d973cc7bce5/uv-0.9.26-py3-none-win_arm64.whl", hash = "sha256:344ff38749b6cd7b7dfdfb382536f168cafe917ae3a5aa78b7a63746ba2a905b", size = 22158123, upload-time = "2026-01-15T20:51:30.939Z" }, +version = "0.9.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/70/611bcee4385b7aa00cf7acf29bc51854c365ee09ddb2cdb14b07a36b4db8/uv-0.9.27.tar.gz", hash = "sha256:9147862902b5d40894f78339803225e39b0535c4c04537188de160eb7635e46b", size = 3830404, upload-time = "2026-01-26T23:27:43.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f1/9e9afb9fadb73f7914a0fc63b6f9d182b6fe6b1e55deb7cbdc29ff31299f/uv-0.9.27-py3-none-linux_armv6l.whl", hash = "sha256:ce3f16e66a96dcdc63f6ada9f7747686930986d2df104a9dd2d09664b2d870c8", size = 22011502, upload-time = "2026-01-26T23:27:31.714Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/c36a87f5c745d310b7d8a53df053d6a87864aa38e3a964b0845eb6de37cc/uv-0.9.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a662a7df5cc781ae7baa65171b5d488d946ea93e61b7bbeda5a24d21a0cd9003", size = 21081065, upload-time = "2026-01-26T23:28:11.895Z" }, + { url = "https://files.pythonhosted.org/packages/44/1d/be2d80573c531389933059f6e5ef265ef7324c268f3ade80e500aa627f6b/uv-0.9.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f00158023e77600da602c5f1fa97cd8c2eef987d6aba34c16cf04a3e5a932f4", size = 19844905, upload-time = "2026-01-26T23:27:34.486Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f7/59679af9f0446d8ffc1239e3356390c95925e0004549b64df3f189b1422b/uv-0.9.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5f2393051ed2023cc7d6ff99e41184b7c7bb7da001bc727cd4bee6da96f4a966", size = 21592623, upload-time = "2026-01-26T23:27:51.132Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/0faaad82951fc6b14dfad8e187e43747a528aa50ee283385f903e86d67d1/uv-0.9.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3f8cf7a50a95ae5cb0366d24edf79d15b3ba380b462af49e3404f9f7b91101c7", size = 21636917, upload-time = "2026-01-26T23:27:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8a/e181c32b7f5309fd987667d368fb944822c713e92a7eba3c73d2eddec6cd/uv-0.9.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c331e0445465ea6029e2dd0f5499024e552136c10069fac0ca21708f2aeb1ce6", size = 21633082, upload-time = "2026-01-26T23:28:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1d44157bc8e5d1c382db087d9a30ab85fc7b5c2d610fb2e3d861c5a69d9b/uv-0.9.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56e0c92da67060d4c86a3b92b2c69e8fb1d61933636764aba16531ddb13f6e3", size = 22843044, upload-time = "2026-01-26T23:27:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/eb/76/7c1b13e4dc8237dd3721f4ec933bb2e5be400fd2812cf98dc2be645a0f7d/uv-0.9.27-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0c9b2e874f5207a50b852726f3a0740eadf30baf2c7973380d697f4e3166d347", size = 24141329, upload-time = "2026-01-26T23:27:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/6f/04/551749fd863cb43290c9a3f4348ccdd88ec0445c26a00ba840d776572867/uv-0.9.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a2e4df4a32f22fbb0919c3e513226684572fba227b37467ba6404f3fafd", size = 23637517, upload-time = "2026-01-26T23:27:37.111Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/78b619a51a6646af4633714b161f980ab764cc892e0f79228162fba51fe8/uv-0.9.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33902c95b561ac67d15c339fe1eaf39e068372c7464c79c3bd0e2bf9ee914dcb", size = 22864516, upload-time = "2026-01-26T23:27:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/b35928e55307beb69b60b88446df3cb8d7ff3ba0993fc2214a43266c17d1/uv-0.9.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79939f7e92d707fb84933509df747d1b88b00d94ebe41f3a1e30916cc33c7307", size = 22746151, upload-time = "2026-01-26T23:27:26.166Z" }, + { url = "https://files.pythonhosted.org/packages/9f/70/fbab20d40afe7ac9ec20011acec75f8bb3b9b83dfbe2cdb1405cad7a8cf2/uv-0.9.27-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7e2d4183a0dca7596ea6385e9d5a0a87ada4f71a70aa110e2b22234370b8d8ef", size = 21661188, upload-time = "2026-01-26T23:27:53.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/02/4d4cf298bd22e53d6c289404b093cf876e64ee1fb946cc32a6f965030629/uv-0.9.27-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1555ab7bc8501144e8771e54a628eb02cb95f3612d54659bb7132576260feee5", size = 22397798, upload-time = "2026-01-26T23:28:07.102Z" }, + { url = "https://files.pythonhosted.org/packages/97/29/3acef6a0eea58afbf7f7a08e4258430e3c7394a6b1e28249450f4c0ddc60/uv-0.9.27-py3-none-musllinux_1_1_i686.whl", hash = "sha256:542731a6f53072e725959a9c839b195048715d840213d9834d36f74fa4249855", size = 22111665, upload-time = "2026-01-26T23:27:58.762Z" }, + { url = "https://files.pythonhosted.org/packages/13/15/1e7b34f02e8f53c9498311f991421e794ad57fa60a2d3e41b43485e914e4/uv-0.9.27-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4f534ad701ca3fffac4a8e1df2a36930e6a0cbf4dad52aeabc2c3c9e2cbbe65e", size = 22951420, upload-time = "2026-01-26T23:27:48.818Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c5/c3bb3a5885891ed8fe7dcc897db03366f3e19da8bf48ae8f5ea4da34545d/uv-0.9.27-py3-none-win32.whl", hash = "sha256:18aab0e19634997366907a9b8a1648e79b0fa34d1b86d8e8ee1e7ba5b9faa6ae", size = 20817398, upload-time = "2026-01-26T23:28:14.265Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/22d2671928f6d2fef1edfdbf758abbb5a0f218b69fd23bd5fd52bbe5b078/uv-0.9.27-py3-none-win_amd64.whl", hash = "sha256:f961c53f83ae6a01e3ffacc584c91044958bc6db003e803c490e106a79981222", size = 23412228, upload-time = "2026-01-26T23:28:01.547Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9a71112e2266b79da552a4b2ffb52331ca7171437b901705427f8e54e77c/uv-0.9.27-py3-none-win_arm64.whl", hash = "sha256:463327fb343c3085a3333389d6e5908cb48203b327707790c06bdc8f2ca57b95", size = 21836206, upload-time = "2026-01-26T23:28:04.411Z" }, ] [[package]] @@ -1838,11 +1838,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.4.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/0a/dc5110cc99c39df65bac29229c4b637a8304e0914850348d98974c8ecfff/wcwidth-0.4.0.tar.gz", hash = "sha256:46478e02cf7149ba150fb93c39880623ee7e5181c64eda167b6a1de51b7a7ba1", size = 237625, upload-time = "2026-01-26T02:35:58.844Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/6e/62daec357285b927e82263a81f3b4c1790215bc77c42530ce4a69d501a43/wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333", size = 246585, upload-time = "2026-01-27T01:31:44.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/f6/da704c5e77281d71723bffbd926b754c0efd57cbcd02e74c2ca374c14cef/wcwidth-0.4.0-py3-none-any.whl", hash = "sha256:8af2c81174b3aa17adf05058c543c267e4e5b6767a28e31a673a658c1d766783", size = 88216, upload-time = "2026-01-26T02:35:57.461Z" }, + { url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" }, ] [[package]] From f0bc1db6d86b2fe6f6b456d712deca3956a74535 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 01:36:05 +0000 Subject: [PATCH 08/12] Generate new screengrabs with rich-codex --- docs/img/perdoo-archive-view.svg | 120 ++++++++++--------- docs/img/perdoo-commands.svg | 126 ++++++++++---------- docs/img/perdoo-import.svg | 178 ++++++++++------------------ docs/img/perdoo-settings-locate.svg | 108 +++++++++++------ docs/img/perdoo-settings-view.svg | 108 +++++++++++------ 5 files changed, 327 insertions(+), 313 deletions(-) diff --git a/docs/img/perdoo-archive-view.svg b/docs/img/perdoo-archive-view.svg index dcaa5f5..d38e8f8 100644 --- a/docs/img/perdoo-archive-view.svg +++ b/docs/img/perdoo-archive-view.svg @@ -1,4 +1,4 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + - + - + - - -Usage: Perdoo archive view [OPTIONS] TARGET - - View the ComicInfo/MetronInfo inside a Comic archive.                           - -╭─ Arguments ──────────────────────────────────────────────────────────────────╮ -*    target      FILE  Comic to view details of. [required] -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---hide-comic-info           Don't show the ComicInfo details.                 ---hide-metron-info          Don't show the MetronInfo details.                ---help                      Show this message and exit.                       -╰──────────────────────────────────────────────────────────────────────────────╯ - + + Traceback (most recent call last): +  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> +    from perdoo.__main__ import app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> +    from perdoo.cli import archive_app, settings_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> +    from perdoo.cli.archive import app as archive_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> +    from perdoo.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> +    from perdoo.comic.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> +    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> +    from perdoo.comic.archives.sevenzip import CB7Archive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> +    from perdoo.comic.archive._base import Archive +ModuleNotFoundError: No module named 'perdoo.comic.archive' diff --git a/docs/img/perdoo-commands.svg b/docs/img/perdoo-commands.svg index 145a08d..d38e8f8 100644 --- a/docs/img/perdoo-commands.svg +++ b/docs/img/perdoo-commands.svg @@ -1,4 +1,4 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - + - - -Usage: Perdoo [OPTIONS] COMMAND [ARGS]... - - CLI tool for managing comic collections and settings.                           - -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---version                     Show the version and exit.                      ---install-completion          Install completion for the current shell.       ---show-completion             Show completion for the current shell, to copy  -                               it or customize the installation.               ---help                        Show this message and exit.                     -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -import   Import comics into your collection using Perdoo.                    -archive  Commands for inspecting and managing comic archive metadata.        -settings Commands for managing and configuring application settings.         -╰──────────────────────────────────────────────────────────────────────────────╯ - + + Traceback (most recent call last): +  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> +    from perdoo.__main__ import app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> +    from perdoo.cli import archive_app, settings_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> +    from perdoo.cli.archive import app as archive_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> +    from perdoo.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> +    from perdoo.comic.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> +    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> +    from perdoo.comic.archives.sevenzip import CB7Archive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> +    from perdoo.comic.archive._base import Archive +ModuleNotFoundError: No module named 'perdoo.comic.archive' diff --git a/docs/img/perdoo-import.svg b/docs/img/perdoo-import.svg index f0a7507..d38e8f8 100644 --- a/docs/img/perdoo-import.svg +++ b/docs/img/perdoo-import.svg @@ -1,4 +1,4 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - + - - -Usage: Perdoo import [OPTIONS] TARGET - - Import comics into your collection using Perdoo.                                - -╭─ Arguments ──────────────────────────────────────────────────────────────────╮ -*    target      PATH  Import comics from the specified file/folder.          -[required]                                    -╰──────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---skip-convert  Skip converting comics to the  -                                                configured format.             ---sync-s[force|outdated|skip]  Sync ComicInfo/MetronInfo      -                                                with online services.          -[default: Outdated]           ---skip-clean  Skip removing any files not    -                                                listed in the                  -                                                'image_extensions' setting.    ---skip-rename  Skip organizing and renaming   -                                                comics based on their          -                                                MetronInfo/ComicInfo.          ---clean-c  Clean the cache before         -                                                starting the synchronization   -                                                process. Removes all cached    -                                                files.                         ---debug  Enable debug mode to show      -                                                extra information.             ---help  Show this message and exit.    -╰──────────────────────────────────────────────────────────────────────────────╯ - + + Traceback (most recent call last): +  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> +    from perdoo.__main__ import app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> +    from perdoo.cli import archive_app, settings_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> +    from perdoo.cli.archive import app as archive_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> +    from perdoo.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> +    from perdoo.comic.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> +    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> +    from perdoo.comic.archives.sevenzip import CB7Archive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> +    from perdoo.comic.archive._base import Archive +ModuleNotFoundError: No module named 'perdoo.comic.archive' diff --git a/docs/img/perdoo-settings-locate.svg b/docs/img/perdoo-settings-locate.svg index 8b7c0c4..d38e8f8 100644 --- a/docs/img/perdoo-settings-locate.svg +++ b/docs/img/perdoo-settings-locate.svg @@ -1,4 +1,4 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - -Usage: Perdoo settings locate [OPTIONS] - - Display the path to the settings file.                                          - -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---help          Show this message and exit.                                   -╰──────────────────────────────────────────────────────────────────────────────╯ - + + Traceback (most recent call last): +  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> +    from perdoo.__main__ import app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> +    from perdoo.cli import archive_app, settings_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> +    from perdoo.cli.archive import app as archive_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> +    from perdoo.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> +    from perdoo.comic.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> +    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> +    from perdoo.comic.archives.sevenzip import CB7Archive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> +    from perdoo.comic.archive._base import Archive +ModuleNotFoundError: No module named 'perdoo.comic.archive' diff --git a/docs/img/perdoo-settings-view.svg b/docs/img/perdoo-settings-view.svg index 27f3b26..d38e8f8 100644 --- a/docs/img/perdoo-settings-view.svg +++ b/docs/img/perdoo-settings-view.svg @@ -1,4 +1,4 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - -Usage: Perdoo settings view [OPTIONS] - - Display the current and default settings.                                       - -╭─ Options ────────────────────────────────────────────────────────────────────╮ ---help          Show this message and exit.                                   -╰──────────────────────────────────────────────────────────────────────────────╯ - + + Traceback (most recent call last): +  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> +    from perdoo.__main__ import app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> +    from perdoo.cli import archive_app, settings_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> +    from perdoo.cli.archive import app as archive_app +  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> +    from perdoo.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> +    from perdoo.comic.comic import Comic +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> +    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> +    from perdoo.comic.archives.sevenzip import CB7Archive +  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> +    from perdoo.comic.archive._base import Archive +ModuleNotFoundError: No module named 'perdoo.comic.archive' From 3d0840b1992953f74a97377321096c77b3960147 Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:39:56 +1300 Subject: [PATCH 09/12] Fix tests --- perdoo/comic/archives/sevenzip.py | 2 +- perdoo/comic/archives/zip.py | 2 +- perdoo/comic/comic.py | 14 ++--- tests/comic/archives/__init__.py | 0 tests/comic/archives/conftest.py | 62 +++++++++++++++++++ tests/comic/archives/test_base.py | 61 ++++++++++++++++++ tests/comic/archives/test_session.py | 44 +++++++++++++ tests/comic/archives/test_sevenzip.py | 64 +++++++++++++++++++ tests/comic/archives/test_tar.py | 58 +++++++++++++++++ tests/comic/archives/test_zip.py | 84 +++++++++++++++++++++++++ tests/comic_test.py | 89 --------------------------- tests/naming_test.py | 37 ----------- 12 files changed, 381 insertions(+), 136 deletions(-) create mode 100644 tests/comic/archives/__init__.py create mode 100644 tests/comic/archives/conftest.py create mode 100644 tests/comic/archives/test_base.py create mode 100644 tests/comic/archives/test_session.py create mode 100644 tests/comic/archives/test_sevenzip.py create mode 100644 tests/comic/archives/test_tar.py create mode 100644 tests/comic/archives/test_zip.py delete mode 100644 tests/comic_test.py delete mode 100644 tests/naming_test.py diff --git a/perdoo/comic/archives/sevenzip.py b/perdoo/comic/archives/sevenzip.py index 1e92480..a14a719 100644 --- a/perdoo/comic/archives/sevenzip.py +++ b/perdoo/comic/archives/sevenzip.py @@ -7,7 +7,7 @@ from tempfile import TemporaryDirectory from typing import ClassVar -from perdoo.comic.archive._base import Archive +from perdoo.comic.archives._base import Archive from perdoo.comic.errors import ComicArchiveError from perdoo.utils import list_files diff --git a/perdoo/comic/archives/zip.py b/perdoo/comic/archives/zip.py index 919009c..6e88933 100644 --- a/perdoo/comic/archives/zip.py +++ b/perdoo/comic/archives/zip.py @@ -82,7 +82,7 @@ def rename_file(self, filename: str, new_name: str, override: bool = False) -> N if new_name in archive.namelist(): if not override: raise ComicArchiveError( - f"Unable to rename {filename} as {new_name} already extsts in {self.filepath.name}." + f"Unable to rename {filename} as {new_name} already exists in {self.filepath.name}." ) removed.append(archive.remove(new_name)) removed.append(archive.remove(archive.copy(filename, new_name))) diff --git a/perdoo/comic/comic.py b/perdoo/comic/comic.py index 3093880..26bf43b 100644 --- a/perdoo/comic/comic.py +++ b/perdoo/comic/comic.py @@ -37,15 +37,13 @@ def convert_to(self, extension: Literal["cbz", "cbt", "cb7"]) -> None: self._archive = cls.convert_from(old_archive=self.archive) def read_metadata(self, session: ArchiveSession) -> tuple[MetronInfo | None, ComicInfo | None]: - metroninfo = None + metron_info = None if session.contains(filename=MetronInfo.FILENAME): - metroninfo = MetronInfo.from_bytes(content=session.read(filename=MetronInfo.FILENAME)) - - comicinfo = None + metron_info = MetronInfo.from_bytes(content=session.read(filename=MetronInfo.FILENAME)) + comic_info = None if session.contains(filename=ComicInfo.FILENAME): - comicinfo = ComicInfo.from_bytes(content=session.read(filename=ComicInfo.FILENAME)) - - return metroninfo, comicinfo + comic_info = ComicInfo.from_bytes(content=session.read(filename=ComicInfo.FILENAME)) + return metron_info, comic_info def list_images(self) -> list[Path]: return humansorted( @@ -73,7 +71,7 @@ def validate_naming(self, naming: str) -> bool: return all(img.name.startswith(template) for img in self.list_images()) def move_to(self, naming: str, output_folder: Path) -> None: - output = output_folder / f"{naming}{self.archive.EXTENSION}" + output = output_folder / (naming + self.archive.EXTENSION) if output.exists(): LOGGER.warning("'%s' already exists, skipping", output) return diff --git a/tests/comic/archives/__init__.py b/tests/comic/archives/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/comic/archives/conftest.py b/tests/comic/archives/conftest.py new file mode 100644 index 0000000..fd4f7bd --- /dev/null +++ b/tests/comic/archives/conftest.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest + +from perdoo.comic.archives import CB7Archive, CBTArchive, CBZArchive + + +@pytest.fixture +def archive_files() -> dict[str, bytes]: + return {"info.txt": b"Fake data", "001.jpg": b"Fake image"} + + +@pytest.fixture +def src(tmp_path: Path) -> Path: + src = tmp_path / "src" + src.mkdir(parents=True, exist_ok=True) + return src + + +@pytest.fixture +def cbz_path(src: Path, archive_files: dict[str, bytes]) -> Path: + created: list[Path] = [] + for filename, data in archive_files.items(): + tmp = src / filename + tmp.write_bytes(data) + created.append(tmp) + return CBZArchive.archive_files(src=src, output_name="sample", files=created) + + +@pytest.fixture +def cbz_archive(cbz_path: Path) -> CBZArchive: + return CBZArchive(filepath=cbz_path) + + +@pytest.fixture +def cbt_path(src: Path, archive_files: dict[str, bytes]) -> Path: + created: list[Path] = [] + for filename, data in archive_files.items(): + tmp = src / filename + tmp.write_bytes(data) + created.append(tmp) + return CBTArchive.archive_files(src=src, output_name="sample", files=created) + + +@pytest.fixture +def cbt_archive(cbt_path: Path) -> CBTArchive: + return CBTArchive(filepath=cbt_path) + + +@pytest.fixture +def cb7_path(src: Path, archive_files: dict[str, bytes]) -> Path: + created: list[Path] = [] + for filename, data in archive_files.items(): + tmp = src / filename + tmp.write_bytes(data) + created.append(tmp) + return CB7Archive.archive_files(src=src, output_name="sample", files=created) + + +@pytest.fixture +def cb7_archive(cb7_path: Path) -> CB7Archive: + return CB7Archive(filepath=cb7_path) diff --git a/tests/comic/archives/test_base.py b/tests/comic/archives/test_base.py new file mode 100644 index 0000000..a0142b9 --- /dev/null +++ b/tests/comic/archives/test_base.py @@ -0,0 +1,61 @@ +from pathlib import Path +from typing import ClassVar + +import pytest + +from perdoo.comic.archives import Archive, CBTArchive, CBZArchive +from perdoo.comic.errors import ComicArchiveError + + +def test_load_selects_cbz(cbz_path: Path) -> None: + loaded = Archive.load(filepath=cbz_path) + + assert isinstance(loaded, CBZArchive) + assert loaded.filepath == cbz_path + + +def test_load_selects_cbt(cbt_path: Path) -> None: + loaded = Archive.load(filepath=cbt_path) + + assert isinstance(loaded, CBTArchive) + assert loaded.filepath == cbt_path + + +def test_load_unsupported(tmp_path: Path) -> None: + tmp = tmp_path / "sample.xyz" + tmp.write_bytes(b"Unsupported") + + with pytest.raises(ComicArchiveError, match=r"Unsupported archive format"): + Archive.load(filepath=tmp) + + +def test_default_operations(tmp_path: Path, cbz_archive: CBZArchive) -> None: + class DummyArchive(Archive): + EXTENSION: ClassVar[str] = ".xyz" + IS_READABLE: ClassVar[bool] = False + IS_WRITEABLE: ClassVar[bool] = False + IS_EDITABLE: ClassVar[bool] = False + + @classmethod + def is_archive(cls, path: Path) -> bool: # noqa: ARG003 + return False + + def list_filenames(self) -> list[str]: + return [] + + def extract_files(self, destination: Path) -> None: # noqa: ARG002 + return None + + tmp = DummyArchive(filepath=tmp_path / "sample.xyz") + with pytest.raises(ComicArchiveError, match=r"Unable to read"): + tmp.read_file(filename="info.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to write"): + tmp.write_file(filename="info.txt", data=b"Hello World") + with pytest.raises(ComicArchiveError, match=r"Unable to delete"): + tmp.delete_file(filename="info.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to rename"): + tmp.rename_file(filename="info.txt", new_name="new.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to archive"): + DummyArchive.archive_files(src=tmp_path, output_name="sample", files=[]) + with pytest.raises(ComicArchiveError, match=r"Unable to convert"): + DummyArchive.convert_from(old_archive=cbz_archive) diff --git a/tests/comic/archives/test_session.py b/tests/comic/archives/test_session.py new file mode 100644 index 0000000..f9ac7d3 --- /dev/null +++ b/tests/comic/archives/test_session.py @@ -0,0 +1,44 @@ +import pytest + +from perdoo.comic.archives import ArchiveSession, CBTArchive, CBZArchive +from perdoo.comic.errors import ComicArchiveError + + +def test_editable_session(cbz_archive: CBZArchive) -> None: + with ArchiveSession(archive=cbz_archive) as session: + assert session.contains(filename="info.txt") + assert session.read(filename="info.txt") == b"Fake data" + + session.write(filename="info.txt", data="Updated data") + session.write(filename="src.txt", data=b"Hello World") + session.rename(filename="src.txt", new_name="new.txt") + session.delete(filename="001.jpg") + + assert cbz_archive.read_file(filename="info.txt") == b"Updated data" + assert "001.jpg" not in cbz_archive.list_filenames() + assert "src.txt" not in cbz_archive.list_filenames() + assert "new.txt" in cbz_archive.list_filenames() + + +def test_non_editable_session(cbt_archive: CBTArchive) -> None: + with ArchiveSession(archive=cbt_archive) as session: + assert session.contains(filename="info.txt") + assert session.read(filename="info.txt") == b"Fake data" + + session.write(filename="info.txt", data="Updated data") + session.write(filename="src.txt", data=b"Hello World") + session.rename(filename="src.txt", new_name="new.txt") + session.delete(filename="001.jpg") + + # TODO: assert cbt_archive.read_file(filename="info.txt") != b"Updated data" + assert "001.jpg" in cbt_archive.list_filenames() + assert "src.txt" not in cbt_archive.list_filenames() + assert "new.txt" not in cbt_archive.list_filenames() + + +def test_session_rename_raises_exception(cbz_archive: CBZArchive) -> None: + with ArchiveSession(cbz_archive) as session: + with pytest.raises(ComicArchiveError, match=r"Unable to rename"): + session.rename(filename="new.txt", new_name="info.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to rename"): + session.rename(filename="info.txt", new_name="001.jpg", override=False) diff --git a/tests/comic/archives/test_sevenzip.py b/tests/comic/archives/test_sevenzip.py new file mode 100644 index 0000000..30d8986 --- /dev/null +++ b/tests/comic/archives/test_sevenzip.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest + +from perdoo.comic.archives import CB7Archive, CBZArchive +from perdoo.comic.archives.sevenzip import PY7ZR_AVAILABLE +from perdoo.comic.errors import ComicArchiveError +from perdoo.utils import list_files + +pytestmark = pytest.mark.skipif(not PY7ZR_AVAILABLE, reason="py7zr not installed") + + +def test_is_archive(cb7_path: Path) -> None: + assert CB7Archive.is_archive(path=cb7_path) is True + + +def test_list_filenames(cb7_archive: CB7Archive) -> None: + assert set(cb7_archive.list_filenames()) == {"info.txt", "001.jpg"} + + +def test_read_file(cb7_archive: CB7Archive) -> None: + assert cb7_archive.read_file(filename="info.txt") == b"Fake data" + assert cb7_archive.read_file(filename="001.jpg") == b"Fake image" + + +def test_unsupported_functions(cb7_archive: CB7Archive) -> None: + with pytest.raises(ComicArchiveError, match=r"Unable to write"): + cb7_archive.write_file(filename="info.txt", data=b"Updated data") + with pytest.raises(ComicArchiveError, match=r"Unable to delete"): + cb7_archive.delete_file(filename="info.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to rename"): + cb7_archive.rename_file(filename="info.txt", new_name="new.txt") + + +def test_extract_files(cb7_archive: CB7Archive, tmp_path: Path) -> None: + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + cb7_archive.extract_files(destination=dest) + + assert (dest / "info.txt").read_text(encoding="UTF-8") == "Fake data" + assert (dest / "001.jpg").read_bytes() == b"Fake image" + + +def test_archive_files(cb7_archive: CB7Archive, tmp_path: Path) -> None: + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + cb7_archive.extract_files(destination=dest) + archive = CB7Archive.archive_files( + src=dest, output_name=cb7_archive.filepath.stem, files=list_files(path=dest) + ) + + assert cb7_archive.filepath == archive + + +def test_convert_from(cbz_archive: CBZArchive) -> None: + old_filenames = cbz_archive.list_filenames() + archive = CB7Archive.convert_from(old_archive=cbz_archive) + + assert isinstance(archive, CB7Archive) + assert archive.filepath.suffix == ".cb7" + assert archive.filepath.exists() + assert not cbz_archive.filepath.exists() + assert archive.list_filenames() == old_filenames + assert archive.read_file(filename="info.txt") == b"Fake data" diff --git a/tests/comic/archives/test_tar.py b/tests/comic/archives/test_tar.py new file mode 100644 index 0000000..25f33af --- /dev/null +++ b/tests/comic/archives/test_tar.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import pytest + +from perdoo.comic.archives import CBTArchive, CBZArchive +from perdoo.comic.errors import ComicArchiveError +from perdoo.utils import list_files + + +def test_is_archive(cbt_path: Path) -> None: + assert CBTArchive.is_archive(path=cbt_path) is True + + +def test_list_filenames(cbt_archive: CBTArchive) -> None: + assert set(cbt_archive.list_filenames()) == {"info.txt", "001.jpg"} + + +def test_unsupported_functions(cbt_archive: CBTArchive) -> None: + with pytest.raises(ComicArchiveError, match=r"Unable to read"): + cbt_archive.read_file(filename="info.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to write"): + cbt_archive.write_file(filename="info.txt", data=b"Updated data") + with pytest.raises(ComicArchiveError, match=r"Unable to delete"): + cbt_archive.delete_file(filename="info.txt") + with pytest.raises(ComicArchiveError, match=r"Unable to rename"): + cbt_archive.rename_file(filename="info.txt", new_name="new.txt") + + +def test_extract_files(cbt_archive: CBTArchive, tmp_path: Path) -> None: + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + cbt_archive.extract_files(destination=dest) + + assert (dest / "info.txt").read_text(encoding="UTF-8") == "Fake data" + assert (dest / "001.jpg").read_bytes() == b"Fake image" + + +def test_archive_files(cbt_archive: CBTArchive, tmp_path: Path) -> None: + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + cbt_archive.extract_files(destination=dest) + archive = CBTArchive.archive_files( + src=dest, output_name=cbt_archive.filepath.stem, files=list_files(path=dest) + ) + + assert cbt_archive.filepath == archive + + +def test_convert_from(cbz_archive: CBZArchive) -> None: + old_filenames = cbz_archive.list_filenames() + archive = CBTArchive.convert_from(old_archive=cbz_archive) + + assert isinstance(archive, CBTArchive) + assert archive.filepath.suffix == ".cbt" + assert archive.filepath.exists() + assert not cbz_archive.filepath.exists() + assert archive.list_filenames() == old_filenames + # TODO: assert archive.read_file(filename="info.txt") == b"Fake data" diff --git a/tests/comic/archives/test_zip.py b/tests/comic/archives/test_zip.py new file mode 100644 index 0000000..ac750af --- /dev/null +++ b/tests/comic/archives/test_zip.py @@ -0,0 +1,84 @@ +from pathlib import Path + +import pytest + +from perdoo.comic.archives import CBTArchive, CBZArchive +from perdoo.comic.errors import ComicArchiveError +from perdoo.utils import list_files + + +def test_is_archive(cbz_path: Path) -> None: + assert CBZArchive.is_archive(path=cbz_path) is True + + +def test_list_filenames(cbz_archive: CBZArchive) -> None: + assert set(cbz_archive.list_filenames()) == {"info.txt", "001.jpg"} + + +def test_read_file(cbz_archive: CBZArchive) -> None: + assert cbz_archive.read_file(filename="info.txt") == b"Fake data" + assert cbz_archive.read_file(filename="001.jpg") == b"Fake image" + + +def test_write_file(cbz_archive: CBZArchive) -> None: + cbz_archive.write_file(filename="info.txt", data=b"Updated data") + assert cbz_archive.read_file(filename="info.txt") == b"Updated data" + + assert "new.txt" not in cbz_archive.list_filenames() + cbz_archive.write_file(filename="new.txt", data=b"Hello World") + assert "new.txt" in cbz_archive.list_filenames() + assert cbz_archive.read_file(filename="new.txt") == b"Hello World" + + +def test_delete_file(cbz_archive: CBZArchive) -> None: + assert "info.txt" in cbz_archive.list_filenames() + cbz_archive.delete_file(filename="info.txt") + assert "info.txt" not in cbz_archive.list_filenames() + cbz_archive.delete_file(filename="info.txt") + assert "info.txt" not in cbz_archive.list_filenames() + + +def test_rename_file(cbz_archive: CBZArchive) -> None: + cbz_archive.write_file(filename="new.txt", data=b"Hello World") + with pytest.raises(ComicArchiveError, match=r"doesn't exist"): + cbz_archive.rename_file(filename="missing.txt", new_name="new.txt") + with pytest.raises(ComicArchiveError, match=r"already exist"): + cbz_archive.rename_file(filename="new.txt", new_name="info.txt", override=False) + + cbz_archive.rename_file(filename="new.txt", new_name="info.txt", override=True) + names = set(cbz_archive.list_filenames()) + assert "new.txt" not in names + assert "info.txt" in names + assert cbz_archive.read_file(filename="info.txt") == b"Hello World" + + +def test_extract_files(cbz_archive: CBZArchive, tmp_path: Path) -> None: + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + cbz_archive.extract_files(destination=dest) + + assert (dest / "info.txt").read_text(encoding="UTF-8") == "Fake data" + assert (dest / "001.jpg").read_bytes() == b"Fake image" + + +def test_archive_files(cbz_archive: CBZArchive, tmp_path: Path) -> None: + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + cbz_archive.extract_files(destination=dest) + archive = CBZArchive.archive_files( + src=dest, output_name=cbz_archive.filepath.stem, files=list_files(path=dest) + ) + + assert cbz_archive.filepath == archive + + +def test_convert_from(cbt_archive: CBTArchive) -> None: + old_filenames = cbt_archive.list_filenames() + archive = CBZArchive.convert_from(old_archive=cbt_archive) + + assert isinstance(archive, CBZArchive) + assert archive.filepath.suffix == ".cbz" + assert archive.filepath.exists() + assert not cbt_archive.filepath.exists() + assert archive.list_filenames() == old_filenames + assert archive.read_file(filename="info.txt") == b"Fake data" diff --git a/tests/comic_test.py b/tests/comic_test.py deleted file mode 100644 index 3f2970a..0000000 --- a/tests/comic_test.py +++ /dev/null @@ -1,89 +0,0 @@ -import tarfile -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from zipremove import ZipFile - -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 image_file(tmp_path: Path) -> Path: - filepath = tmp_path / "page1.jpg" - filepath.write_bytes(b"Fake image") - return filepath - - -@pytest.fixture -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 -def metron_info() -> MetronInfo: - return MetronInfo(series=Series(name="Test Series")) - - -@pytest.fixture -def comic_info() -> ComicInfo: - return ComicInfo() - - -def test_convert_to_cbz(cbt_comic: Comic) -> None: - cbt_comic.convert_to(extension="cbz") - assert cbt_comic.filepath.suffix == ".cbz" - - -def test_clean_archive(cbz_comic: Comic) -> None: - cbz_comic.archive.list_filenames = MagicMock( - return_value=["image1.jpg", "info.txt", "ComicInfo.xml", "cover.png"] - ) - cbz_comic.archive.remove_file = MagicMock() - - cbz_comic.clean_archive() - 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: - assert cbz_comic.comic_info is None - cbz_comic.write_metadata(metadata=comic_info) - assert cbz_comic.comic_info == comic_info - - -def test_write_metroninfo(cbz_comic: Comic, metron_info: MetronInfo) -> None: - assert cbz_comic.metron_info is None - cbz_comic.write_metadata(metadata=metron_info) - assert cbz_comic.metron_info == metron_info - - -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 - - assert cbz_comic.metron_info is None - cbz_comic.write_metadata(metadata=metron_info) - assert cbz_comic.metron_info == metron_info - cbz_comic.write_metadata(metadata=metadata_copy) - assert cbz_comic.metron_info == metadata_copy - - -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.filepath.parent) - assert cbz_comic.filepath.name == "Test-Series-v1_#.cbz" diff --git a/tests/naming_test.py b/tests/naming_test.py deleted file mode 100644 index a7d3e15..0000000 --- a/tests/naming_test.py +++ /dev/null @@ -1,37 +0,0 @@ -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 - - -def test_sanitize() -> None: - assert sanitize("Example Title!", seperator="-") == "Example-Title!" - assert sanitize("Example/Title: 123", seperator="-") == "ExampleTitle-123" - assert sanitize("!@#$%^&*()[]{};':,.<>?/", seperator="-") == "!&" - assert sanitize(None, seperator="-") is None - - -def test_metron_info_default_naming() -> None: - obj = MetronInfo( - publisher=Publisher(name="Example Publisher"), - series=Series(name="Example Series", volume=1, format=Format.TRADE_PAPERBACK), - number=2, - ) - assert ( - obj.get_filename(settings=Naming()) - == "Example-Publisher/Example-Series-v1/Example-Series-v1_TPB_#02" - ) - - -def test_comic_info_default_naming() -> None: - obj = ComicInfo( - publisher="Example Publisher", - series="Example Series", - format="Trade Paperback", - volume=1, - number=2, - ) - assert ( - obj.get_filename(settings=Naming()) - == "Example-Publisher/Example-Series-v1/Example-Series-v1_TPB_#02" - ) From 83197e89d6b3dbe86c3c2b0fa5c91a3df3dae873 Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:13:55 +1300 Subject: [PATCH 10/12] Rename import command to process Tidy cli commands --- README.md | 28 +- perdoo/__main__.py | 330 +--------------- perdoo/cli/__init__.py | 8 +- perdoo/cli/_typer.py | 24 ++ perdoo/cli/archive.py | 46 ++- perdoo/cli/process.py | 362 ++++++++++++++++++ perdoo/cli/settings.py | 17 +- perdoo/comic/archives/zip.py | 24 +- perdoo/comic/metadata/comic_info.py | 2 +- tests/comic/__init__.py | 0 .../archives/{test_base.py => base_test.py} | 0 .../{test_session.py => session_test.py} | 0 .../{test_sevenzip.py => sevenzip_test.py} | 0 .../archives/{test_tar.py => tar_test.py} | 0 .../archives/{test_zip.py => zip_test.py} | 4 +- 15 files changed, 443 insertions(+), 402 deletions(-) create mode 100644 perdoo/cli/_typer.py create mode 100644 perdoo/cli/process.py create mode 100644 tests/comic/__init__.py rename tests/comic/archives/{test_base.py => base_test.py} (100%) rename tests/comic/archives/{test_session.py => session_test.py} (100%) rename tests/comic/archives/{test_sevenzip.py => sevenzip_test.py} (100%) rename tests/comic/archives/{test_tar.py => tar_test.py} (100%) rename tests/comic/archives/{test_zip.py => zip_test.py} (95%) diff --git a/README.md b/README.md index 72bea58..42b56b9 100644 --- a/README.md +++ b/README.md @@ -26,40 +26,28 @@ Unlike other tagging tools, Perdoo employs a manual approach when metadata files ## Usage -
Perdoo Commands +
perdoo Commands - ![`uv run Perdoo --help`](docs/img/perdoo-commands.svg) + ![`uv run perdoo`](docs/img/perdoo-commands.svg)
-
Perdoo import +
perdoo process - ![`uv run Perdoo import --help`](docs/img/perdoo-import.svg) + ![`uv run perdoo process --help`](docs/img/perdoo-process.svg)
- -### Perdoo archive Commands - -
Perdoo archive view - - - ![`uv run Perdoo archive view --help`](docs/img/perdoo-archive-view.svg) - -
- -### Perdoo settings Commands - -
Perdoo settings view +
perdoo archive - ![`uv run Perdoo settings view --help`](docs/img/perdoo-settings-view.svg) + ![`uv run perdoo archive --help`](docs/img/perdoo-archive.svg)
-
Perdoo settings locate +
perdoo settings - ![`uv run Perdoo settings locate --help`](docs/img/perdoo-settings-locate.svg) + ![`uv run perdoo settings --help`](docs/img/perdoo-settings.svg)
diff --git a/perdoo/__main__.py b/perdoo/__main__.py index c06155e..cb52b44 100644 --- a/perdoo/__main__.py +++ b/perdoo/__main__.py @@ -1,330 +1,4 @@ -import logging -from datetime import date -from enum import Enum -from pathlib import Path -from platform import python_version -from typing import Annotated - -from comicfn2dict import comicfn2dict -from typer import Argument, Context, Exit, Option, Typer - -from perdoo import __version__, get_cache_root, setup_logging -from perdoo.cli import archive_app, settings_app -from perdoo.comic import Comic -from perdoo.comic.archives import ArchiveSession -from perdoo.comic.errors import ComicArchiveError, ComicMetadataError -from perdoo.comic.metadata import ComicInfo, MetronInfo -from perdoo.comic.metadata.metron_info import Id, InformationSource -from perdoo.console import CONSOLE -from perdoo.services import BaseService, Comicvine, Metron -from perdoo.settings import Naming, Output, Service, Services, Settings -from perdoo.utils import ( - IssueSearch, - Search, - SeriesSearch, - delete_empty_folders, - list_files, - recursive_delete, -) - -app = Typer(help="CLI tool for managing comic collections and settings.") -app.add_typer(archive_app, name="archive") -app.add_typer(settings_app, name="settings") -LOGGER = logging.getLogger("perdoo") - - -class SyncOption(str, Enum): - FORCE = "Force" - OUTDATED = "Outdated" - SKIP = "Skip" - - -@app.callback(invoke_without_command=True) -def common( - ctx: Context, - version: Annotated[ - bool | None, Option("--version", is_eager=True, help="Show the version and exit.") - ] = None, -) -> None: - if ctx.invoked_subcommand: - return - if version: - CONSOLE.print(f"Perdoo v{__version__}") - raise Exit - - -def get_services(settings: Services) -> dict[Service, BaseService]: - output = {} - if settings.comicvine.api_key: - output[Service.COMICVINE] = Comicvine(settings.comicvine) - if settings.metron.username and settings.metron.password: - output[Service.METRON] = Metron(settings.metron) - return output - - -def setup_environment( - clean_cache: bool, sync: SyncOption, settings: Settings, debug: bool = False -) -> tuple[dict[Service, BaseService], SyncOption]: - setup_logging(debug=debug) - LOGGER.info("Python v%s", python_version()) - LOGGER.info("Perdoo v%s", __version__) - - if clean_cache: - LOGGER.info("Cleaning Cache") - recursive_delete(path=get_cache_root()) - - services = get_services(settings=settings.services) - if not services and sync is not SyncOption.SKIP: - LOGGER.warning("No external services configured") - sync = SyncOption.SKIP - return services, sync - - -def load_comics(target: Path) -> list[Comic]: - comics = [] - files = list_files(target) if target.is_dir() else [target] - for file in files: - try: - 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 - - -def prepare_comic(entry: Comic, settings: Settings, skip_convert: bool) -> bool: - if not skip_convert: - entry.convert_to(settings.output.format) - if not entry.archive.IS_WRITEABLE: - LOGGER.warning("Archive format %s is not writeable", entry.archive.EXTENSION) - return False - return True - - -def should_sync_metadata(sync: SyncOption, metron_info: MetronInfo | None) -> bool: - if sync is SyncOption.SKIP: - return False - if sync is SyncOption.FORCE: - return True - if metron_info and metron_info.last_modified: - age = (date.today() - metron_info.last_modified.date()).days - return age >= 28 - return True - - -def get_id(ids: list[Id], source: InformationSource) -> str | None: - return next((x.value for x in ids if x.source is source), None) - - -def search_from_metron_info(metron_info: MetronInfo) -> Search: - series_id = metron_info.series.id - source = next((x.source for x in metron_info.ids if x.primary), None) - return Search( - series=SeriesSearch( - name=metron_info.series.name, - volume=metron_info.series.volume, - year=metron_info.series.start_year, - comicvine=series_id if source == InformationSource.COMIC_VINE else None, - metron=series_id if source == InformationSource.METRON else None, - ), - issue=IssueSearch( - number=metron_info.number, - comicvine=get_id(metron_info.ids, InformationSource.COMIC_VINE), - metron=get_id(metron_info.ids, InformationSource.METRON), - ), - ) - - -def search_from_comic_info(comic_info: ComicInfo, filename: str) -> Search: - volume = comic_info.volume - year = volume if volume and volume > 1900 else None - volume = volume if volume and volume < 1900 else None - return Search( - series=SeriesSearch(name=comic_info.series or filename, volume=volume, year=year), - issue=IssueSearch(number=comic_info.number), - ) - - -def search_from_filename(filename: str) -> Search: - series_name = comicfn2dict(filename).get("series", filename).replace("-", " ") - return Search(series=SeriesSearch(name=series_name), issue=IssueSearch()) - - -def build_search( - metron_info: MetronInfo | None, comic_info: ComicInfo | None, filename: str -) -> Search: - if metron_info and metron_info.series and metron_info.series.name: - return search_from_metron_info(metron_info=metron_info) - if comic_info and comic_info.series: - return search_from_comic_info(comic_info=comic_info, filename=filename) - return search_from_filename(filename=filename) - - -def sync_metadata( - search: Search, services: dict[Service, BaseService], service_order: tuple[Service, ...] -) -> tuple[MetronInfo | None, ComicInfo | None]: - for service_name in service_order: - if service := services.get(service_name): - metron_info, comic_info = service.fetch(search=search) - if metron_info or comic_info: - return metron_info, comic_info - return None, None - - -def resolve_metadata( - entry: Comic, - session: ArchiveSession, - services: dict[Service, BaseService], - settings: Services, - sync: SyncOption, -) -> tuple[MetronInfo | None, ComicInfo | None]: - metron_info, comic_info = entry.read_metadata(session=session) - if not should_sync_metadata(sync=sync, metron_info=metron_info): - return metron_info, comic_info - search = build_search( - metron_info=metron_info, comic_info=comic_info, filename=entry.filepath.stem - ) - search.filename = entry.filepath.stem - return sync_metadata(search=search, services=services, service_order=settings.order) - - -def generate_naming( - settings: Naming, metron_info: MetronInfo | None, comic_info: ComicInfo | None -) -> str | None: - filepath = None - if metron_info: - filepath = metron_info.get_filename(settings=settings) - if not filepath and comic_info: - filepath = comic_info.get_filename(settings=settings) - return filepath.lstrip("/") if filepath else None - - -def apply_changes( - entry: Comic, - session: ArchiveSession, - metron_info: MetronInfo | None, - comic_info: ComicInfo | None, - skip_clean: bool, - skip_rename: bool, - settings: Output, -) -> str | None: - local_metron_info, local_comic_info = entry.read_metadata(session=session) - if local_metron_info != metron_info: - if metron_info: - session.write(filename=MetronInfo.FILENAME, data=metron_info.to_bytes()) - else: - session.delete(filename=MetronInfo.FILENAME) - - if local_comic_info != comic_info: - if comic_info: - session.write(filename=ComicInfo.FILENAME, data=comic_info.to_bytes()) - else: - session.delete(filename=ComicInfo.FILENAME) - - if not skip_clean: - for extra in entry.list_extras(): - session.delete(filename=extra.name) - - naming = None - if not skip_rename and ( - naming := generate_naming( - settings=settings.naming, metron_info=metron_info, comic_info=comic_info - ) - ): - images = entry.list_images() - stem = Path(naming).stem - pad = len(str(len(images))) - for idx, img in enumerate(images): - new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" - if img.name != new_name: - session.rename(filename=img.name, new_name=new_name) - return naming - - -@app.command(name="import", help="Import comics into your collection using Perdoo.") -def run( - target: Annotated[ - Path, - Argument( - exists=True, help="Import comics from the specified file/folder.", show_default=False - ), - ], - skip_convert: Annotated[ - bool, Option("--skip-convert", help="Skip converting comics to the configured format.") - ] = False, - sync: Annotated[ - SyncOption, - Option( - "--sync", - "-s", - case_sensitive=False, - help="Sync ComicInfo/MetronInfo with online services.", - ), - ] = SyncOption.OUTDATED, - skip_clean: Annotated[ - bool, - Option( - "--skip-clean", - help="Skip removing any files not listed in the 'image_extensions' setting.", - ), - ] = False, - skip_rename: Annotated[ - bool, - Option( - "--skip-rename", - help="Skip organizing and renaming comics based on their MetronInfo/ComicInfo.", - ), - ] = False, - clean_cache: Annotated[ - bool, - Option( - "--clean", - "-c", - show_default=False, - help="Clean the cache before starting the synchronization process. " - "Removes all cached files.", - ), - ] = False, - debug: Annotated[ - bool, Option("--debug", help="Enable debug mode to show extra information.") - ] = False, -) -> None: - settings = Settings.load() - settings.save() - services, sync = setup_environment( - clean_cache=clean_cache, sync=sync, settings=settings, debug=debug - ) - - comics = load_comics(target=target) - total = len(comics) - for index, entry in enumerate(comics, start=1): - CONSOLE.rule( - f"[{index}/{total}] Importing {entry.filepath.name}", align="left", style="subtitle" - ) - - if not prepare_comic(entry=entry, settings=settings, skip_convert=skip_convert): - continue - with entry.open_session() as session: - metron_info, comic_info = resolve_metadata( - entry=entry, - session=session, - services=services, - settings=settings.services, - sync=sync, - ) - naming = apply_changes( - entry=entry, - session=session, - metron_info=metron_info, - comic_info=comic_info, - skip_clean=skip_clean, - skip_rename=skip_rename, - settings=settings.output, - ) - if naming: - entry.move_to(naming=naming, output_folder=settings.output.folder) - with CONSOLE.status("Cleaning up empty folders"): - delete_empty_folders(folder=target) - +from perdoo.cli import app if __name__ == "__main__": - app(prog_name="Perdoo") + app(prog_name="perdoo") diff --git a/perdoo/cli/__init__.py b/perdoo/cli/__init__.py index e225d71..175f539 100644 --- a/perdoo/cli/__init__.py +++ b/perdoo/cli/__init__.py @@ -1,4 +1,6 @@ -__all__ = ["archive_app", "settings_app"] +__all__ = ["app", "archive", "process", "settings"] -from perdoo.cli.archive import app as archive_app -from perdoo.cli.settings import app as settings_app +from perdoo.cli._typer import app +from perdoo.cli.archive import archive +from perdoo.cli.process import process +from perdoo.cli.settings import settings diff --git a/perdoo/cli/_typer.py b/perdoo/cli/_typer.py new file mode 100644 index 0000000..5e55ca6 --- /dev/null +++ b/perdoo/cli/_typer.py @@ -0,0 +1,24 @@ +__all__ = ["app"] + +from typing import Annotated + +from typer import Context, Exit, Option, Typer + +from perdoo import __version__ +from perdoo.console import CONSOLE + +app = Typer(no_args_is_help=True, help="CLI tool for managing comic collections and settings.") + + +@app.callback(invoke_without_command=True) +def common( + ctx: Context, + version: Annotated[ + bool | None, Option("--version", is_eager=True, help="Show the version and exit.") + ] = None, +) -> None: + if ctx.invoked_subcommand: + return + if version: + CONSOLE.print(f"Perdoo v{__version__}") + raise Exit diff --git a/perdoo/cli/archive.py b/perdoo/cli/archive.py index 61cae3e..69a60e5 100644 --- a/perdoo/cli/archive.py +++ b/perdoo/cli/archive.py @@ -1,38 +1,44 @@ -__all__ = ["app"] +__all__ = [] +import logging from pathlib import Path from typing import Annotated -from typer import Argument, Option, Typer +from typer import Argument, Option +from perdoo.cli._typer import app from perdoo.comic import Comic from perdoo.console import CONSOLE -app = Typer(help="Commands for inspecting and managing comic archive metadata.") +LOGGER = logging.getLogger(__name__) -@app.command(help="View the ComicInfo/MetronInfo inside a Comic archive.") -def view( +@app.command(help="Inspect comic archive metadata.") +def archive( target: Annotated[ Path, Argument(dir_okay=False, exists=True, show_default=False, help="Comic to view details of."), ], - hide_comic_info: Annotated[ - bool, Option("--hide-comic-info", help="Don't show the ComicInfo details.") + skip_comic_info: Annotated[ + bool, Option("--skip-comic-info", help="Don't show the ComicInfo details.") ] = False, - hide_metron_info: Annotated[ - bool, Option("--hide-metron-info", help="Don't show the MetronInfo details.") + skip_metron_info: Annotated[ + bool, Option("--skip-metron-info", help="Don't show the MetronInfo details.") ] = False, ) -> None: + if skip_comic_info and skip_metron_info: + return 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") - else: - comic.metron_info.display() - if not hide_comic_info: - if not comic.comic_info: - CONSOLE.print("No ComicInfo found") - else: - comic.comic_info.display() + LOGGER.info("Format: '%s'", type(comic.archive).__name__) + with comic.open_session() as session: + metron_info, comic_info = comic.read_metadata(session=session) + if not skip_comic_info: + if comic_info: + comic_info.display() + else: + CONSOLE.print("No ComicInfo found", style="logging.level.error") + if not skip_metron_info: + if metron_info: + metron_info.display() + else: + CONSOLE.print("No MetronInfo found", style="logging.level.error") diff --git a/perdoo/cli/process.py b/perdoo/cli/process.py new file mode 100644 index 0000000..35544ca --- /dev/null +++ b/perdoo/cli/process.py @@ -0,0 +1,362 @@ +import logging +from datetime import date +from enum import Enum +from pathlib import Path +from platform import python_version +from typing import Annotated + +from comicfn2dict import comicfn2dict +from typer import Argument, Option + +from perdoo import __version__, get_cache_root, setup_logging +from perdoo.cli._typer import app +from perdoo.comic import Comic +from perdoo.comic.archives import ArchiveSession +from perdoo.comic.errors import ComicArchiveError, ComicMetadataError +from perdoo.comic.metadata import ComicInfo, MetronInfo +from perdoo.comic.metadata.metron_info import Id, InformationSource +from perdoo.console import CONSOLE +from perdoo.services import BaseService, Comicvine, Metron +from perdoo.settings import Naming, Output, Service, Services, Settings +from perdoo.utils import ( + IssueSearch, + Search, + SeriesSearch, + delete_empty_folders, + list_files, + recursive_delete, +) + +LOGGER = logging.getLogger(__name__) + + +class SyncOption(str, Enum): + FORCE = "Force" + OUTDATED = "Outdated" + SKIP = "Skip" + + +def get_services(settings: Services) -> dict[Service, BaseService]: + output = {} + if settings.comicvine.api_key: + output[Service.COMICVINE] = Comicvine(settings.comicvine) + if settings.metron.username and settings.metron.password: + output[Service.METRON] = Metron(settings.metron) + return output + + +def setup_environment( + clean_cache: bool, sync: SyncOption, settings: Settings, debug: bool = False +) -> tuple[dict[Service, BaseService], SyncOption]: + setup_logging(debug=debug) + LOGGER.info("Python v%s", python_version()) + LOGGER.info("Perdoo v%s", __version__) + + if clean_cache: + LOGGER.info("Cleaning Cache") + recursive_delete(path=get_cache_root()) + + services = get_services(settings=settings.services) + if not services and sync is not SyncOption.SKIP: + LOGGER.warning("No external services configured") + sync = SyncOption.SKIP + return services, sync + + +def load_comics(target: Path) -> list[Comic]: + comics = [] + files = list_files(target) if target.is_dir() else [target] + for file in files: + try: + 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 + + +def prepare_comic(entry: Comic, settings: Settings, skip_convert: bool) -> bool: + if not skip_convert: + entry.convert_to(settings.output.format) + if not entry.archive.IS_WRITEABLE: + LOGGER.warning("Archive format %s is not writeable", entry.archive.EXTENSION) + return False + return True + + +def should_sync_metadata(sync: SyncOption, metron_info: MetronInfo | None) -> bool: + if sync is SyncOption.SKIP: + return False + if sync is SyncOption.FORCE: + return True + if metron_info and metron_info.last_modified: + age = (date.today() - metron_info.last_modified.date()).days + return age >= 28 + return True + + +def get_id(ids: list[Id], source: InformationSource) -> str | None: + return next((x.value for x in ids if x.source is source), None) + + +def search_from_metron_info(metron_info: MetronInfo) -> Search: + series_id = metron_info.series.id + source = next((x.source for x in metron_info.ids if x.primary), None) + return Search( + series=SeriesSearch( + name=metron_info.series.name, + volume=metron_info.series.volume, + year=metron_info.series.start_year, + comicvine=series_id if source == InformationSource.COMIC_VINE else None, + metron=series_id if source == InformationSource.METRON else None, + ), + issue=IssueSearch( + number=metron_info.number, + comicvine=get_id(metron_info.ids, InformationSource.COMIC_VINE), + metron=get_id(metron_info.ids, InformationSource.METRON), + ), + ) + + +def search_from_comic_info(comic_info: ComicInfo, filename: str) -> Search: + volume = comic_info.volume + year = volume if volume and volume > 1900 else None + volume = volume if volume and volume < 1900 else None + return Search( + series=SeriesSearch(name=comic_info.series or filename, volume=volume, year=year), + issue=IssueSearch(number=comic_info.number), + ) + + +def search_from_filename(filename: str) -> Search: + series_name = comicfn2dict(filename).get("series", filename).replace("-", " ") + return Search(series=SeriesSearch(name=series_name), issue=IssueSearch()) + + +def build_search( + metron_info: MetronInfo | None, comic_info: ComicInfo | None, filename: str +) -> Search: + if metron_info and metron_info.series and metron_info.series.name: + return search_from_metron_info(metron_info=metron_info) + if comic_info and comic_info.series: + return search_from_comic_info(comic_info=comic_info, filename=filename) + return search_from_filename(filename=filename) + + +def sync_metadata( + search: Search, services: dict[Service, BaseService], service_order: tuple[Service, ...] +) -> tuple[MetronInfo | None, ComicInfo | None]: + for service_name in service_order: + if service := services.get(service_name): + metron_info, comic_info = service.fetch(search=search) + if metron_info or comic_info: + return metron_info, comic_info + return None, None + + +def resolve_metadata( + entry: Comic, + session: ArchiveSession, + services: dict[Service, BaseService], + settings: Services, + sync: SyncOption, +) -> tuple[MetronInfo | None, ComicInfo | None]: + metron_info, comic_info = entry.read_metadata(session=session) + if not should_sync_metadata(sync=sync, metron_info=metron_info): + return metron_info, comic_info + search = build_search( + metron_info=metron_info, comic_info=comic_info, filename=entry.filepath.stem + ) + search.filename = entry.filepath.stem + return sync_metadata(search=search, services=services, service_order=settings.order) + + +def generate_naming( + settings: Naming, metron_info: MetronInfo | None, comic_info: ComicInfo | None +) -> str | None: + filepath = None + if metron_info: + filepath = metron_info.get_filename(settings=settings) + if not filepath and comic_info: + filepath = comic_info.get_filename(settings=settings) + return filepath.lstrip("/") if filepath else None + + +def apply_changes( + entry: Comic, + session: ArchiveSession, + metron_info: MetronInfo | None, + comic_info: ComicInfo | None, + skip_clean: bool, + skip_rename: bool, + settings: Output, +) -> str | None: + local_metron_info, local_comic_info = entry.read_metadata(session=session) + if local_metron_info != metron_info: + if metron_info: + session.write(filename=MetronInfo.FILENAME, data=metron_info.to_bytes()) + else: + session.delete(filename=MetronInfo.FILENAME) + + if local_comic_info != comic_info: + if comic_info: + session.write(filename=ComicInfo.FILENAME, data=comic_info.to_bytes()) + else: + session.delete(filename=ComicInfo.FILENAME) + + if not skip_clean: + for extra in entry.list_extras(): + session.delete(filename=extra.name) + + naming = None + if not skip_rename and ( + naming := generate_naming( + settings=settings.naming, metron_info=metron_info, comic_info=comic_info + ) + ): + images = entry.list_images() + stem = Path(naming).stem + pad = len(str(len(images))) + for idx, img in enumerate(images): + new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" + if img.name != new_name: + session.rename(filename=img.name, new_name=new_name) + return naming + + +@app.command(help="Process comics by converting, syncing metadata, and organizing them.") +def process( + target: Annotated[ + Path, + Argument( + exists=True, help="Process comics from the specified file/folder.", show_default=False + ), + ], + skip_convert: Annotated[ + bool, Option("--skip-convert", help="Skip converting comics to the configured format.") + ] = False, + sync: Annotated[ + SyncOption, + Option( + "--sync", + "-s", + case_sensitive=False, + help="Sync ComicInfo/MetronInfo with online services.", + ), + ] = SyncOption.OUTDATED, + skip_clean: Annotated[ + bool, Option("--skip-clean", help="Skip removing any non-image/MetronInfo/ComicInfo files.") + ] = False, + skip_rename: Annotated[ + bool, + Option( + "--skip-rename", + help="Skip organizing and renaming comics based on their MetronInfo/ComicInfo.", + ), + ] = False, + clean_cache: Annotated[ + bool, Option("--clean", "-c", show_default=False, help="Remove all cached files.") + ] = False, + debug: Annotated[ + bool, Option("--debug", help="Enable debug mode to show extra information.") + ] = False, +) -> None: + settings = Settings.load() + settings.save() + services, sync = setup_environment( + clean_cache=clean_cache, sync=sync, settings=settings, debug=debug + ) + + comics = load_comics(target=target) + total = len(comics) + for index, entry in enumerate(comics, start=1): + CONSOLE.rule( + f"[{index}/{total}] Importing {entry.filepath.name}", align="left", style="subtitle" + ) + + if not prepare_comic(entry=entry, settings=settings, skip_convert=skip_convert): + continue + with entry.open_session() as session: + metron_info, comic_info = resolve_metadata( + entry=entry, + session=session, + services=services, + settings=settings.services, + sync=sync, + ) + naming = apply_changes( + entry=entry, + session=session, + metron_info=metron_info, + comic_info=comic_info, + skip_clean=skip_clean, + skip_rename=skip_rename, + settings=settings.output, + ) + if naming: + entry.move_to(naming=naming, output_folder=settings.output.folder) + with CONSOLE.status("Cleaning up empty folders"): + delete_empty_folders(folder=target) + + +@app.command( + name="import", + deprecated=True, + help="Use `perdoo process` instead.\nImport comics into your collection using Perdoo.", +) +def run( + target: Annotated[ + Path, + Argument( + exists=True, help="Import comics from the specified file/folder.", show_default=False + ), + ], + skip_convert: Annotated[ + bool, Option("--skip-convert", help="Skip converting comics to the configured format.") + ] = False, + sync: Annotated[ + SyncOption, + Option( + "--sync", + "-s", + case_sensitive=False, + help="Sync ComicInfo/MetronInfo with online services.", + ), + ] = SyncOption.OUTDATED, + skip_clean: Annotated[ + bool, + Option( + "--skip-clean", + help="Skip removing any files not listed in the 'image_extensions' setting.", + ), + ] = False, + skip_rename: Annotated[ + bool, + Option( + "--skip-rename", + help="Skip organizing and renaming comics based on their MetronInfo/ComicInfo.", + ), + ] = False, + clean_cache: Annotated[ + bool, + Option( + "--clean", + "-c", + show_default=False, + help="Clean the cache before starting the synchronization process. " + "Removes all cached files.", + ), + ] = False, + debug: Annotated[ + bool, Option("--debug", help="Enable debug mode to show extra information.") + ] = False, +) -> None: + LOGGER.warning("`perdoo import` is deprecated; use `perdoo process` instead.") + return process( + target=target, + skip_convert=skip_convert, + sync=sync, + skip_clean=skip_clean, + skip_rename=skip_rename, + clean_cache=clean_cache, + debug=debug, + ) diff --git a/perdoo/cli/settings.py b/perdoo/cli/settings.py index d6228a7..eaf8e15 100644 --- a/perdoo/cli/settings.py +++ b/perdoo/cli/settings.py @@ -1,19 +1,10 @@ -__all__ = ["app"] +__all__ = [] -from typer import Typer - -from perdoo.console import CONSOLE +from perdoo.cli._typer import app from perdoo.settings import Settings -app = Typer(help="Commands for managing and configuring application settings.") - -@app.command(name="view", help="Display the current and default settings.") -def view() -> None: +@app.command(help="Display app settings and defaults.") +def settings() -> None: settings = Settings.load() settings.display() - - -@app.command(name="locate", help="Display the path to the settings file.") -def locate() -> None: - CONSOLE.print(Settings.path) diff --git a/perdoo/comic/archives/zip.py b/perdoo/comic/archives/zip.py index 6e88933..3aec3c2 100644 --- a/perdoo/comic/archives/zip.py +++ b/perdoo/comic/archives/zip.py @@ -37,7 +37,7 @@ def list_filenames(self) -> list[str]: 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 + raise ComicArchiveError(f"Unable to list files from {self.filepath.name}.") from err def read_file(self, filename: str) -> bytes: try: @@ -47,7 +47,7 @@ def read_file(self, filename: str) -> bytes: ): return zip_file.read() except Exception as err: - raise ComicArchiveError(f"Unable to read {filename} in {self.filepath.name}") from err + raise ComicArchiveError(f"Unable to read {filename}.") from err def write_file(self, filename: str, data: bytes) -> None: try: @@ -57,7 +57,7 @@ def write_file(self, filename: str, data: bytes) -> None: archive.repack([removed]) archive.writestr(filename, data) except Exception as err: - raise ComicArchiveError(f"Unable to write {filename} to {self.filepath.name}") from err + raise ComicArchiveError(f"Unable to write {filename}.") from err def delete_file(self, filename: str) -> None: if filename not in self.list_filenames(): @@ -67,22 +67,18 @@ def delete_file(self, filename: str) -> None: removed = archive.remove(filename) archive.repack([removed]) except Exception as err: - raise ComicArchiveError( - f"Unable to delete {filename} from {self.filepath.name}" - ) from err + raise ComicArchiveError(f"Unable to delete {filename}.") from err def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: if filename not in self.list_filenames(): - raise ComicArchiveError( - f"Unable to rename {filename} as it doesn't exist in {self.filepath.name}." - ) + raise ComicArchiveError(f"Unable to rename {filename} as it does not exist.") try: removed = [] with ZipFile(file=self.filepath, mode="a") as archive: if new_name in archive.namelist(): if not override: raise ComicArchiveError( - f"Unable to rename {filename} as {new_name} already exists in {self.filepath.name}." + f"Unable to rename {filename} as {new_name} already exists." ) removed.append(archive.remove(new_name)) removed.append(archive.remove(archive.copy(filename, new_name))) @@ -90,9 +86,7 @@ def rename_file(self, filename: str, new_name: str, override: bool = False) -> N except ComicArchiveError: raise except Exception as err: - raise ComicArchiveError( - f"Unable to rename {filename} to {new_name} in {self.filepath.name}" - ) from err + raise ComicArchiveError(f"Unable to rename {filename} to {new_name}.") from err def extract_files(self, destination: Path) -> None: try: @@ -100,7 +94,7 @@ def extract_files(self, destination: Path) -> None: archive.extractall(path=destination) except Exception as err: raise ComicArchiveError( - f"Unable to extract all files from {self.filepath.name} to {destination}" + f"Unable to extract all files from {self.filepath.name} to {destination}." ) from err @classmethod @@ -112,7 +106,7 @@ def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: archive.write(file, arcname=file.name) return output_file except Exception as err: - raise ComicArchiveError(f"Unable to archive files to {output_file.name}") from err + raise ComicArchiveError(f"Unable to archive files to {output_file.name}.") from err @classmethod def convert_from(cls, old_archive: Archive) -> Self: diff --git a/perdoo/comic/metadata/comic_info.py b/perdoo/comic/metadata/comic_info.py index 94c567b..d4d5ecc 100644 --- a/perdoo/comic/metadata/comic_info.py +++ b/perdoo/comic/metadata/comic_info.py @@ -187,7 +187,7 @@ class ComicInfo(Metadata): @computed_attr(ns="xsi", name="noNamespaceSchemaLocation") def schema_location(self) -> str: - return "https://raw.githubusercontent.com/Buried-In-Code/Schemas/main/schemas/v2.0/ComicInfo.xsd" + return "https://raw.githubusercontent.com/anansi-project/comicinfo/main/schema/v2.0/ComicInfo.xsd" @property def cover_date(self) -> date | None: diff --git a/tests/comic/__init__.py b/tests/comic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/comic/archives/test_base.py b/tests/comic/archives/base_test.py similarity index 100% rename from tests/comic/archives/test_base.py rename to tests/comic/archives/base_test.py diff --git a/tests/comic/archives/test_session.py b/tests/comic/archives/session_test.py similarity index 100% rename from tests/comic/archives/test_session.py rename to tests/comic/archives/session_test.py diff --git a/tests/comic/archives/test_sevenzip.py b/tests/comic/archives/sevenzip_test.py similarity index 100% rename from tests/comic/archives/test_sevenzip.py rename to tests/comic/archives/sevenzip_test.py diff --git a/tests/comic/archives/test_tar.py b/tests/comic/archives/tar_test.py similarity index 100% rename from tests/comic/archives/test_tar.py rename to tests/comic/archives/tar_test.py diff --git a/tests/comic/archives/test_zip.py b/tests/comic/archives/zip_test.py similarity index 95% rename from tests/comic/archives/test_zip.py rename to tests/comic/archives/zip_test.py index ac750af..6cbf28e 100644 --- a/tests/comic/archives/test_zip.py +++ b/tests/comic/archives/zip_test.py @@ -40,9 +40,9 @@ def test_delete_file(cbz_archive: CBZArchive) -> None: def test_rename_file(cbz_archive: CBZArchive) -> None: cbz_archive.write_file(filename="new.txt", data=b"Hello World") - with pytest.raises(ComicArchiveError, match=r"doesn't exist"): + with pytest.raises(ComicArchiveError, match=r"does not exist"): cbz_archive.rename_file(filename="missing.txt", new_name="new.txt") - with pytest.raises(ComicArchiveError, match=r"already exist"): + with pytest.raises(ComicArchiveError, match=r"already exists"): cbz_archive.rename_file(filename="new.txt", new_name="info.txt", override=False) cbz_archive.rename_file(filename="new.txt", new_name="info.txt", override=True) From cc1e0190d56779b26ce7a522dd906f0a28cf9988 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 08:51:15 +0000 Subject: [PATCH 11/12] Generate new screengrabs with rich-codex --- docs/img/perdoo-archive.svg | 116 ++++++++++++++++++++++++ docs/img/perdoo-commands.svg | 136 +++++++++++++++------------- docs/img/perdoo-process.svg | 170 +++++++++++++++++++++++++++++++++++ docs/img/perdoo-settings.svg | 94 +++++++++++++++++++ 4 files changed, 455 insertions(+), 61 deletions(-) create mode 100644 docs/img/perdoo-archive.svg create mode 100644 docs/img/perdoo-process.svg create mode 100644 docs/img/perdoo-settings.svg diff --git a/docs/img/perdoo-archive.svg b/docs/img/perdoo-archive.svg new file mode 100644 index 0000000..a6c233b --- /dev/null +++ b/docs/img/perdoo-archive.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Usage: perdoo archive [OPTIONS] TARGET + + Inspect comic archive metadata.                                                 + +╭─ Arguments ──────────────────────────────────────────────────────────────────╮ +*    target      FILE  Comic to view details of. [required] +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +--skip-comic-info           Don't show the ComicInfo details.                 +--skip-metron-info          Don't show the MetronInfo details.                +--help                      Show this message and exit.                       +╰──────────────────────────────────────────────────────────────────────────────╯ + + + + + diff --git a/docs/img/perdoo-commands.svg b/docs/img/perdoo-commands.svg index d38e8f8..3e4f4f7 100644 --- a/docs/img/perdoo-commands.svg +++ b/docs/img/perdoo-commands.svg @@ -1,4 +1,4 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + - + - + - - Traceback (most recent call last): -  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> -    from perdoo.__main__ import app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> -    from perdoo.cli import archive_app, settings_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> -    from perdoo.cli.archive import app as archive_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> -    from perdoo.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> -    from perdoo.comic.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> -    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> -    from perdoo.comic.archives.sevenzip import CB7Archive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> -    from perdoo.comic.archive._base import Archive -ModuleNotFoundError: No module named 'perdoo.comic.archive' + + +Usage: perdoo [OPTIONS] COMMAND [ARGS]... + + CLI tool for managing comic collections and settings.                           + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +--version                     Show the version and exit.                      +--install-completion          Install completion for the current shell.       +--show-completion             Show completion for the current shell, to copy  +                               it or customize the installation.               +--help                        Show this message and exit.                     +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +archive  Inspect comic archive metadata.                                     +process  Process comics by converting, syncing metadata, and                 + organizing them.                                                    +import   Use `perdoo process` instead.                        (deprecated)  + Import comics into your collection using Perdoo.                    +settings Display app settings and defaults.                                  +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/docs/img/perdoo-process.svg b/docs/img/perdoo-process.svg new file mode 100644 index 0000000..3bf9717 --- /dev/null +++ b/docs/img/perdoo-process.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Usage: perdoo process [OPTIONS] TARGET + + Process comics by converting, syncing metadata, and organizing them.            + +╭─ Arguments ──────────────────────────────────────────────────────────────────╮ +*    target      PATH  Process comics from the specified file/folder.         +[required]                                     +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +--skip-convert  Skip converting comics to the  +                                                configured format.             +--sync-s[force|outdated|skip]  Sync ComicInfo/MetronInfo      +                                                with online services.          +[default: Outdated]           +--skip-clean  Skip removing any              +                                                non-image/MetronInfo/ComicIn…  +                                                files.                         +--skip-rename  Skip organizing and renaming   +                                                comics based on their          +                                                MetronInfo/ComicInfo.          +--clean-c  Remove all cached files.       +--debug  Enable debug mode to show      +                                                extra information.             +--help  Show this message and exit.    +╰──────────────────────────────────────────────────────────────────────────────╯ + + + + + diff --git a/docs/img/perdoo-settings.svg b/docs/img/perdoo-settings.svg new file mode 100644 index 0000000..03e932e --- /dev/null +++ b/docs/img/perdoo-settings.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Usage: perdoo settings [OPTIONS] + + Display app settings and defaults.                                              + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +--help          Show this message and exit.                                   +╰──────────────────────────────────────────────────────────────────────────────╯ + + + + + From 1e66c25403ca8d9ddddfd6fb70bde8c73a01da20 Mon Sep 17 00:00:00 2001 From: Buried-In-Code <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:58:41 +1300 Subject: [PATCH 12/12] Tidy up rich-codex --- .github/workflows/rich-codex.yaml | 3 +- docs/img/perdoo-archive-view.svg | 126 ---------------------------- docs/img/perdoo-import.svg | 126 ---------------------------- docs/img/perdoo-settings-locate.svg | 126 ---------------------------- docs/img/perdoo-settings-view.svg | 126 ---------------------------- 5 files changed, 1 insertion(+), 506 deletions(-) delete mode 100644 docs/img/perdoo-archive-view.svg delete mode 100644 docs/img/perdoo-import.svg delete mode 100644 docs/img/perdoo-settings-locate.svg delete mode 100644 docs/img/perdoo-settings-view.svg diff --git a/.github/workflows/rich-codex.yaml b/.github/workflows/rich-codex.yaml index 771ffd9..d05d6b6 100644 --- a/.github/workflows/rich-codex.yaml +++ b/.github/workflows/rich-codex.yaml @@ -4,7 +4,6 @@ on: push: paths: - README.md - - perdoo/__main__.py - perdoo/cli/** workflow_dispatch: @@ -26,4 +25,4 @@ jobs: uses: ewels/rich-codex@v1 with: commit_changes: true - clean_img_paths: true + clean_img_paths: docs/img/*.svg diff --git a/docs/img/perdoo-archive-view.svg b/docs/img/perdoo-archive-view.svg deleted file mode 100644 index d38e8f8..0000000 --- a/docs/img/perdoo-archive-view.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Traceback (most recent call last): -  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> -    from perdoo.__main__ import app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> -    from perdoo.cli import archive_app, settings_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> -    from perdoo.cli.archive import app as archive_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> -    from perdoo.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> -    from perdoo.comic.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> -    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> -    from perdoo.comic.archives.sevenzip import CB7Archive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> -    from perdoo.comic.archive._base import Archive -ModuleNotFoundError: No module named 'perdoo.comic.archive' - - - - diff --git a/docs/img/perdoo-import.svg b/docs/img/perdoo-import.svg deleted file mode 100644 index d38e8f8..0000000 --- a/docs/img/perdoo-import.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Traceback (most recent call last): -  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> -    from perdoo.__main__ import app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> -    from perdoo.cli import archive_app, settings_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> -    from perdoo.cli.archive import app as archive_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> -    from perdoo.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> -    from perdoo.comic.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> -    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> -    from perdoo.comic.archives.sevenzip import CB7Archive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> -    from perdoo.comic.archive._base import Archive -ModuleNotFoundError: No module named 'perdoo.comic.archive' - - - - diff --git a/docs/img/perdoo-settings-locate.svg b/docs/img/perdoo-settings-locate.svg deleted file mode 100644 index d38e8f8..0000000 --- a/docs/img/perdoo-settings-locate.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Traceback (most recent call last): -  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> -    from perdoo.__main__ import app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> -    from perdoo.cli import archive_app, settings_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> -    from perdoo.cli.archive import app as archive_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> -    from perdoo.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> -    from perdoo.comic.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> -    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> -    from perdoo.comic.archives.sevenzip import CB7Archive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> -    from perdoo.comic.archive._base import Archive -ModuleNotFoundError: No module named 'perdoo.comic.archive' - - - - diff --git a/docs/img/perdoo-settings-view.svg b/docs/img/perdoo-settings-view.svg deleted file mode 100644 index d38e8f8..0000000 --- a/docs/img/perdoo-settings-view.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Traceback (most recent call last): -  File "/home/runner/work/Perdoo/Perdoo/.venv/bin/Perdoo", line 4, in <module> -    from perdoo.__main__ import app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/__main__.py", line 12, in <module> -    from perdoo.cli import archive_app, settings_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/__init__.py", line 3, in <module> -    from perdoo.cli.archive import app as archive_app -  File "/home/runner/work/Perdoo/Perdoo/perdoo/cli/archive.py", line 8, in <module> -    from perdoo.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/__init__.py", line 3, in <module> -    from perdoo.comic.comic import Comic -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/comic.py", line 10, in <module> -    from perdoo.comic.archives import Archive, ArchiveSession, CB7Archive, CBTArchive, CBZArchive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/__init__.py", line 6, in <module> -    from perdoo.comic.archives.sevenzip import CB7Archive -  File "/home/runner/work/Perdoo/Perdoo/perdoo/comic/archives/sevenzip.py", line 10, in <module> -    from perdoo.comic.archive._base import Archive -ModuleNotFoundError: No module named 'perdoo.comic.archive' - - - -