diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 234843c..311c53d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,11 +38,16 @@ repos: rev: 22.3.0 hooks: - id: black - args: [--line-length=120] +- repo: https://github.com/PyCQA/autoflake + rev: v1.4 + hooks: + - id: autoflake + args: [-i] - repo: https://github.com/PyCQA/flake8 rev: 4.0.1 hooks: - id: flake8 + additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-eradicate] - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.950 hooks: diff --git a/comicapi/comet.py b/comicapi/comet.py index 8962708..4bec783 100644 --- a/comicapi/comet.py +++ b/comicapi/comet.py @@ -206,8 +206,8 @@ class CoMet: tree = ET.ElementTree(ET.fromstring(string)) root = tree.getroot() if root.tag != "comet": - raise Exception - except: + return False + except ET.ParseError: return False return True diff --git a/comicapi/comicarchive.py b/comicapi/comicarchive.py index 83b943a..6e15e08 100644 --- a/comicapi/comicarchive.py +++ b/comicapi/comicarchive.py @@ -21,15 +21,12 @@ import pathlib import platform import struct import subprocess -import sys import tarfile import tempfile import time import zipfile from typing import cast -from typing import List -from typing import Optional -from typing import Union +from typing import IO import natsort import py7zr @@ -48,7 +45,7 @@ try: from unrar.cffi import rarfile rar_support = True -except: +except ImportError: rar_support = False try: @@ -61,9 +58,7 @@ except ImportError: logger = logging.getLogger(__name__) if not pil_available: - logger.exception("PIL unavalable") - -sys.path.insert(0, os.path.abspath(".")) + logger.error("PIL unavalable") class MetaDataStyle: @@ -86,8 +81,8 @@ class UnknownArchiver: def set_comment(self, comment: str) -> bool: return False - def read_file(self, archive_file: str) -> bytes | None: - return None + def read_file(self, archive_file: str) -> bytes: + raise NotImplementedError def write_file(self, archive_file: str, data: bytes) -> bool: return False @@ -98,6 +93,12 @@ class UnknownArchiver: def get_filename_list(self) -> list[str]: return [] + def rebuild(self, exclude_list: list[str]) -> bool: + return False + + def copy_from_archive(self, other_archive: UnknownArchiver) -> bool: + return False + class ZipArchiver(UnknownArchiver): @@ -112,39 +113,30 @@ class ZipArchiver(UnknownArchiver): return comment def set_comment(self, comment: str) -> bool: - with zipfile.ZipFile(self.path, "a") as zf: + with zipfile.ZipFile(self.path, mode="a") as zf: zf.comment = bytes(comment, "utf-8") return True def read_file(self, archive_file: str) -> bytes: - with zipfile.ZipFile(self.path, "r") as zf: + with zipfile.ZipFile(self.path, mode="r") as zf: try: data = zf.read(archive_file) - except zipfile.BadZipfile as e: - logger.error("bad zipfile [%s]: %s :: %s", e, self.path, archive_file) - raise OSError from e except Exception as e: logger.error("bad zipfile [%s]: %s :: %s", e, self.path, archive_file) - raise OSError from e + raise return data def remove_file(self, archive_file: str) -> bool: - try: - self.rebuild_zip_file([archive_file]) - except: - logger.exception("Failed to remove %s from zip archive", archive_file) - return False - else: - return True + return self.rebuild([archive_file]) def write_file(self, archive_file: str, data: bytes) -> bool: - # At the moment, no other option but to rebuild the whole - # zip archive w/o the indicated file. Very sucky, but maybe + # At the moment, no other option but to rebuild the whole + # zip archive w/o the indicated file. Very sucky, but maybe # another solution can be found try: files = self.get_filename_list() if archive_file in files: - self.rebuild_zip_file([archive_file]) + self.rebuild([archive_file]) # now just add the archive file as a new one with zipfile.ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf: @@ -156,24 +148,23 @@ class ZipArchiver(UnknownArchiver): def get_filename_list(self) -> list[str]: try: - with zipfile.ZipFile(self.path, "r") as zf: + with zipfile.ZipFile(self.path, mode="r") as zf: namelist = zf.namelist() return namelist except Exception as e: logger.error("Unable to get zipfile list [%s]: %s", e, self.path) return [] - def rebuild_zip_file(self, exclude_list: list[str]) -> None: + def rebuild(self, exclude_list: list[str]) -> bool: """Zip helper func This recompresses the zip archive, without the files in the exclude_list """ - tmp_fd, tmp_name = tempfile.mkstemp(dir=os.path.dirname(self.path)) - os.close(tmp_fd) - try: - with zipfile.ZipFile(self.path, "r") as zin: - with zipfile.ZipFile(tmp_name, "w", allowZip64=True) as zout: + with zipfile.ZipFile( + tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False), "w", allowZip64=True + ) as zout: + with zipfile.ZipFile(self.path, mode="r") as zin: for item in zin.infolist(): buffer = zin.read(item.filename) if item.filename not in exclude_list: @@ -181,12 +172,14 @@ class ZipArchiver(UnknownArchiver): # preserve the old comment zout.comment = zin.comment - except Exception: - logger.exception("Error rebuilding zip file: %s", self.path) + # replace with the new file + os.remove(self.path) + os.rename(cast(str, zout.filename), self.path) - # replace with the new file - os.remove(self.path) - os.rename(tmp_name, self.path) + except (OSError, zipfile.BadZipfile): + logger.exception("Error rebuilding zip file") + return False + return True def write_zip_comment(self, filename: pathlib.Path | str, comment: str) -> bool: """ @@ -203,7 +196,7 @@ class ZipArchiver(UnknownArchiver): file_length = statinfo.st_size try: - with open(filename, "r+b") as fo: + with open(filename, mode="r+b") as fo: # the starting position, relative to EOF pos = -4 @@ -252,7 +245,7 @@ class ZipArchiver(UnknownArchiver): def copy_from_archive(self, other_archive: UnknownArchiver) -> bool: """Replace the current zip with one copied from another archive""" try: - with zipfile.ZipFile(self.path, "w", allowZip64=True) as zout: + with zipfile.ZipFile(self.path, mode="w", allowZip64=True) as zout: for fname in other_archive.get_filename_list(): data = other_archive.read_file(fname) if data is not None: @@ -264,7 +257,7 @@ class ZipArchiver(UnknownArchiver): if not self.write_zip_comment(self.path, comment): return False except Exception: - logger.exception("Error while copying to %s", self.path) + logger.exception("Error while copying archive from %s to %s", other_archive.path, self.path) return False else: return True @@ -272,103 +265,110 @@ class ZipArchiver(UnknownArchiver): class TarArchiver(UnknownArchiver): def __init__(self, path: pathlib.Path | str) -> None: - self.path = path + self.path = pathlib.Path(path) + # tar archives take filenames literally `./ComicInfo.xml` is not `ComicInfo.xml` + self.path_norm: dict[str, str] = {} def get_comment(self) -> str: - return comment + return "" def set_comment(self, comment: str) -> bool: - return self.writeTarComment(self.path, comment) + return False - def read_file(self, archive_file: str) -> bytes | None: - tf = tarfile.TarFile(self.path, "r") + def read_file(self, archive_file: str) -> bytes: + with tarfile.TarFile(self.path, mode="r", encoding="utf-8") as tf: + archive_file = str(pathlib.PurePosixPath(archive_file)) + if not self.path_norm: + self.get_filename_list() + if archive_file in self.path_norm: + archive_file = self.path_norm[archive_file] - try: - data = tf.extractfile(archive_file).read() - except tarfile.TarError as e: - errMsg = f"bad tarfile [{e}]: {self.path} :: {archive_file}" - logger.info(errMsg) - tf.close() - raise OSError - except Exception as e: - tf.close() - errMsg = f"bad tarfile [{e}]: {self.path} :: {archive_file}" - logger.info(errMsg) - raise OSError - finally: - tf.close() - return data + try: + with cast(IO[bytes], tf.extractfile(archive_file)) as file: + data = file.read() + except (tarfile.TarError, OSError) as e: + logger.info(f"bad tarfile [{e}]: {self.path} :: {archive_file}") + raise + return data def remove_file(self, archive_file: str) -> bool: - try: - self.rebuild_tar_file([archive_file]) - except: - return False - else: - return True + return self.rebuild([archive_file]) def write_file(self, archive_file: str, data: bytes) -> bool: # At the moment, no other option but to rebuild the whole # zip archive w/o the indicated file. Very sucky, but maybe # another solution can be found try: - self.rebuild_tar_file([archive_file]) + self.rebuild([archive_file]) + archive_file = str(pathlib.PurePosixPath(archive_file)) # now just add the archive file as a new one - tf = tarfile.Tarfile(self.path, mode="a") - tf.writestr(archive_file, data) - tf.close() + with tarfile.TarFile(self.path, mode="a") as tf: + tfi = tarfile.TarInfo(archive_file) + tfi.size = len(data) + tfi.mtime = int(time.time()) + tf.addfile(tfi, io.BytesIO(data)) return True - except: + except Exception: return False def get_filename_list(self) -> list[str]: try: - tf = tarfile.TarFile(self.path, "r") - namelist = tf.getnames() + tf = tarfile.TarFile(self.path, mode="r", encoding="utf-8") + namelist = [] + for name in tf.getnames(): + new_name = pathlib.PurePosixPath(name) + namelist.append(str(new_name)) + self.path_norm[str(new_name)] = name + tf.close() return namelist - except Exception as e: - errMsg = f"Unable to get tarfile list [{e}]: {self.path}" - logger.info(errMsg) + except (OSError, tarfile.TarError) as e: + logger.info(f"Unable to get tarfile list [{e}]: {self.path}") return [] - # zip helper func - def rebuild_tar_file(self, exclude_list: list[str]) -> None: + def rebuild(self, exclude_list: list[str]) -> bool: """Tar helper func This re-creates the tar archive without the files in the exclude list """ - # generate temp file - tmp_fd, tmp_name = tempfile.mkstemp(dir=os.path.dirname(self.path)) - os.close(tmp_fd) - try: - with tarfile.TarFile(self.path, "r") as tin: - with tarfile.TarFile(tmp_name, "w") as tout: + with tarfile.TarFile( + fileobj=tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False), + mode="w", + encoding="utf-8", + ) as tout: + with tarfile.TarFile(self.path, mode="r", encoding="utf-8") as tin: for item in tin.getmembers(): buffer = tin.extractfile(item) - if item.name not in exclude_list: + if str(pathlib.PurePosixPath(item.name)) not in exclude_list: tout.addfile(item, buffer) - except Exception: - logger.exception("Error rebuilding tar file: %s", self.path) - # replace with the new file - os.remove(self.path) - os.rename(tmp_name, self.path) + # replace with the new file + os.remove(self.path) + os.rename(str(tout.name), self.path) + + except (OSError, tarfile.TarError): + logger.exception("Error rebuilding tar file") + return False + return True def copy_from_archive(self, other_archive: UnknownArchiver) -> bool: # Replace the current tar with one copied from another archive try: - with zipfile.ZipFile(self.path, "w", allowZip64=True) as zout: + with tarfile.TarFile(self.path, mode="w", encoding="utf-8") as tout: for fname in other_archive.get_filename_list(): data = other_archive.read_file(fname) if data is not None: - tout.addfile(fname, data) + # All archives should use `/` as the directory separator + tfi = tarfile.TarInfo(str(pathlib.PurePosixPath(fname))) + tfi.size = len(data) + tfi.mtime = int(time.time()) + tout.addfile(tfi, io.BytesIO(data)) - except Exception as e: - logger.exception("Error while copying to %s", self.path) + except Exception: + logger.exception("Error while copying archive from %s to %s", other_archive.path, self.path) return False else: return True @@ -403,28 +403,22 @@ class SevenZipArchiver(UnknownArchiver): return data def remove_file(self, archive_file: str) -> bool: - try: - self.rebuild_zip_file([archive_file]) - except: - logger.exception("Failed to remove %s from 7zip archive", archive_file) - return False - else: - return True + return self.rebuild([archive_file]) def write_file(self, archive_file: str, data: bytes) -> bool: - # At the moment, no other option but to rebuild the whole - # zip archive w/o the indicated file. Very sucky, but maybe + # At the moment, no other option but to rebuild the whole + # zip archive w/o the indicated file. Very sucky, but maybe # another solution can be found - try: - files = self.get_filename_list() - if archive_file in files: - self.rebuild_zip_file([archive_file]) + files = self.get_filename_list() + if archive_file in files: + self.rebuild([archive_file]) + try: # now just add the archive file as a new one with py7zr.SevenZipFile(self.path, "a") as zf: zf.writestr(data, archive_file) return True - except: + except (py7zr.Bad7zFile, OSError): logger.exception("Writing zip file failed") return False @@ -434,42 +428,41 @@ class SevenZipArchiver(UnknownArchiver): namelist: list[str] = zf.getnames() return namelist - except Exception as e: + except (py7zr.Bad7zFile, OSError) as e: logger.error("Unable to get 7zip file list [%s]: %s", e, self.path) return [] - def rebuild_zip_file(self, exclude_list: list[str]) -> None: + def rebuild(self, exclude_list: list[str]) -> bool: """Zip helper func This recompresses the zip archive, without the files in the exclude_list """ - tmp_fd, tmp_name = tempfile.mkstemp(dir=os.path.dirname(self.path)) - os.close(tmp_fd) try: - with py7zr.SevenZipFile(self.path, "r") as zin: + with py7zr.SevenZipFile(self.path, mode="r") as zin: targets = [f for f in zin.getnames() if f not in exclude_list] - with py7zr.SevenZipFile(self.path, "r") as zin: - with py7zr.SevenZipFile(tmp_name, "w") as zout: - for fname, bio in zin.read(targets).items(): - zout.writef(bio, fname) - except Exception: + with tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False) as tmp_file: + with py7zr.SevenZipFile(tmp_file.file, mode="w") as zout: + with py7zr.SevenZipFile(self.path, mode="r") as zin: + for fname, bio in zin.read(targets).items(): + zout.writef(bio, fname) + os.remove(self.path) + os.rename(tmp_file.name, self.path) + except (py7zr.Bad7zFile, OSError): logger.exception("Error rebuilding 7zip file: %s", self.path) + return False + return True - # replace with the new file - os.remove(self.path) - os.rename(tmp_name, self.path) - - def copy_from_archive(self, otherArchive: UnknownArchiver) -> bool: + def copy_from_archive(self, other_archive: UnknownArchiver) -> bool: """Replace the current zip with one copied from another archive""" try: with py7zr.SevenZipFile(self.path, "w") as zout: - for fname in otherArchive.get_filename_list(): - data = otherArchive.read_file(fname) + for fname in other_archive.get_filename_list(): + data = other_archive.read_file(fname) if data is not None: zout.writestr(data, fname) - except Exception as e: - logger.exception("Error while copying to %s: %s", self.path, e) + except Exception: + logger.exception("Error while copying archive from %s to %s", other_archive.path, self.path) return False else: return True @@ -480,8 +473,8 @@ class RarArchiver(UnknownArchiver): devnull = None - def __init__(self, path: pathlib.Path | str, rar_exe_path: str) -> None: - self.path = pathlib.Path(path) + def __init__(self, path: pathlib.Path | str, rar_exe_path: str = "rar") -> None: + self.path: pathlib.Path = pathlib.Path(path) self.rar_exe_path = rar_exe_path if RarArchiver.devnull is None: @@ -502,25 +495,30 @@ class RarArchiver(UnknownArchiver): if self.rar_exe_path: try: # write comment to temp file - tmp_fd, tmp_name = tempfile.mkstemp() - with os.fdopen(tmp_fd, "wb") as f: - f.write(comment.encode("utf-8")) + with tempfile.NamedTemporaryFile() as tmp_file: + tmp_file.write(comment.encode("utf-8")) - working_dir = os.path.dirname(os.path.abspath(self.path)) + working_dir = os.path.dirname(os.path.abspath(self.path)) - # use external program to write comment to Rar archive - proc_args = [self.rar_exe_path, "c", "-w" + working_dir, "-c-", "-z" + tmp_name, self.path] - subprocess.call( - proc_args, - startupinfo=self.startupinfo, - stdout=RarArchiver.devnull, - stdin=RarArchiver.devnull, - stderr=RarArchiver.devnull, - ) + # use external program to write comment to Rar archive + proc_args = [ + self.rar_exe_path, + "c", + f"-w{working_dir}", + "-c-", + f"-z{tmp_file.name}", + str(self.path), + ] + subprocess.call( + proc_args, + startupinfo=self.startupinfo, + stdout=RarArchiver.devnull, + stdin=RarArchiver.devnull, + stderr=RarArchiver.devnull, + ) if platform.system() == "Darwin": time.sleep(1) - os.remove(tmp_name) except Exception: logger.exception("Failed to set a comment") return False @@ -601,8 +599,7 @@ class RarArchiver(UnknownArchiver): time.sleep(1) os.remove(tmp_file) os.rmdir(tmp_folder) - except Exception as e: - logger.info(str(e)) + except Exception: logger.exception("Failed write %s to rar archive", archive_file) return False else: @@ -612,23 +609,21 @@ class RarArchiver(UnknownArchiver): def remove_file(self, archive_file: str) -> bool: if self.rar_exe_path: - try: - # use external program to remove file from Rar archive - subprocess.call( - [self.rar_exe_path, "d", "-c-", self.path, archive_file], - startupinfo=self.startupinfo, - stdout=RarArchiver.devnull, - stdin=RarArchiver.devnull, - stderr=RarArchiver.devnull, - ) + # use external program to remove file from Rar archive + result = subprocess.run( + [self.rar_exe_path, "d", "-c-", self.path, archive_file], + startupinfo=self.startupinfo, + stdout=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) - if platform.system() == "Darwin": - time.sleep(1) - except: + if platform.system() == "Darwin": + time.sleep(1) + if result.returncode != 0: logger.exception("Failed to remove %s from rar archive", archive_file) return False - else: - return True + return True else: return False @@ -659,6 +654,33 @@ class RarArchiver(UnknownArchiver): return None + def copy_from_archive(self, other_archive: UnknownArchiver) -> bool: + """Replace the current zip with one copied from another archive""" + try: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = pathlib.Path(tmp_dir) + rar_cwd = tmp_path / "rar" + rar_cwd.mkdir(exist_ok=True) + rar_path = (tmp_path / self.path.name).with_suffix(".rar") + for fname in other_archive.get_filename_list(): + (rar_cwd / fname).parent.mkdir(exist_ok=True) + with open(rar_cwd / fname, mode="w+b") as tmp_file: + tmp_file.write(other_archive.read_file(fname)) + subprocess.run( + [self.rar_exe_path, "a", f"-w{rar_cwd}", "-c-", rar_path, "."], + startupinfo=self.startupinfo, + stdout=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + os.rename(rar_path, self.path) + except Exception: + logger.exception("Error while copying archive from %s to %s", other_archive.path, self.path) + return False + else: + return True + class FolderArchiver(UnknownArchiver): @@ -679,10 +701,11 @@ class FolderArchiver(UnknownArchiver): data = bytes() fname = os.path.join(self.path, archive_file) try: - with open(fname, "rb") as f: + with open(fname, mode="rb") as f: data = f.read() except OSError: logger.exception("Failed to read: %s", fname) + raise return data @@ -690,9 +713,9 @@ class FolderArchiver(UnknownArchiver): fname = os.path.join(self.path, archive_file) try: - with open(fname, "wb") as f: + with open(fname, mode="wb") as f: f.write(data) - except: + except OSError: logger.exception("Failed to write: %s", fname) return False else: @@ -703,7 +726,7 @@ class FolderArchiver(UnknownArchiver): fname = os.path.join(self.path, archive_file) try: os.remove(fname) - except: + except OSError: logger.exception("Failed to remove: %s", fname) return False else: @@ -728,7 +751,7 @@ class ComicArchive: logo_data = bytes() class ArchiveType: - Zip, Rar, SevenZip, Tar, Folder, Pdf, Unknown = list(range(6)) + Zip, Rar, SevenZip, Tar, Folder, Pdf, Unknown = list(range(7)) def __init__( self, @@ -785,7 +808,7 @@ class ComicArchive: self.archiver = RarArchiver(self.path, rar_exe_path=self.rar_exe_path) if not ComicArchive.logo_data and self.default_image_path: - with open(self.default_image_path, "rb") as fd: + with open(self.default_image_path, mode="rb") as fd: ComicArchive.logo_data = fd.read() def reset_cache(self) -> None: @@ -815,14 +838,13 @@ class ComicArchive: def zip_test(self) -> bool: return zipfile.is_zipfile(self.path) - def tar_test(self): + def tar_test(self) -> bool: return tarfile.is_tarfile(self.path) def rar_test(self) -> bool: - try: - return bool(rarfile.is_rarfile(str(self.path))) - except: - return False + if rar_support: + return rarfile.is_rarfile(str(self.path)) + return False def is_sevenzip(self) -> bool: return self.archive_type == self.ArchiveType.SevenZip @@ -830,7 +852,7 @@ class ComicArchive: def is_zip(self) -> bool: return self.archive_type == self.ArchiveType.Zip - def is_tar(self): + def is_tar(self) -> bool: return self.archive_type == self.ArchiveType.Tar def is_rar(self) -> bool: @@ -957,7 +979,7 @@ class ComicArchive: length_buckets[length] = 1 # sort by most common - sorted_buckets = sorted(length_buckets.items(), key=lambda k, v: (v, k), reverse=True) + sorted_buckets = sorted(length_buckets.items(), key=lambda tup: (tup[1], tup[0]), reverse=True) # statistical mode occurrence is first mode_length = sorted_buckets[0][0] @@ -1086,7 +1108,7 @@ class ComicArchive: try: raw_cix = self.archiver.read_file(self.ci_xml_filename) or b"" except OSError as e: - logger.error("Error reading in raw CIX!: %s", e) + logger.error("Error reading in raw CIX! for %s: %s", self.path, e) raw_cix = bytes() return raw_cix @@ -1155,12 +1177,9 @@ class ComicArchive: logger.info("%s doesn't have CoMet data!", self.path) raw_comet = "" - try: - raw_bytes = self.archiver.read_file(cast(str, self.comet_filename)) - if raw_bytes: - raw_comet = raw_bytes.decode("utf-8") - except: - logger.exception("Error reading in raw CoMet!") + raw_bytes = self.archiver.read_file(cast(str, self.comet_filename)) + if raw_bytes: + raw_comet = raw_bytes.decode("utf-8", "replace") return raw_comet def write_comet(self, metadata: GenericMetadata) -> bool: @@ -1211,7 +1230,7 @@ class ComicArchive: if d: data = d.decode("utf-8") except Exception as e: - logger.warning("Error reading in Comet XML for validation!: %s", e) + logger.warning("Error reading in Comet XML for validation! from %s: %s", self.path, e) if CoMet().validate_string(data): # since we found it, save it! self.comet_filename = n @@ -1241,7 +1260,7 @@ class ComicArchive: p["ImageHeight"] = str(h) p["ImageWidth"] = str(w) except Exception as e: - logger.warning("decoding image failed: %s", e) + logger.warning("decoding image failed for %s: %s", self.path, e) p["ImageSize"] = str(len(data)) else: diff --git a/comicapi/comicbookinfo.py b/comicapi/comicbookinfo.py index 47b3c45..447dd4c 100644 --- a/comicapi/comicbookinfo.py +++ b/comicapi/comicbookinfo.py @@ -21,7 +21,6 @@ from datetime import datetime from typing import Any from typing import Literal from typing import TypedDict -from typing import Union from comicapi import utils from comicapi.genericmetadata import GenericMetadata @@ -126,7 +125,7 @@ class ComicBookInfo: try: cbi_container = json.loads(string) - except: + except json.JSONDecodeError: return False return "ComicBookInfo/1.0" in cbi_container diff --git a/comicapi/comicinfoxml.py b/comicapi/comicinfoxml.py index 1edeb6f..103c899 100644 --- a/comicapi/comicinfoxml.py +++ b/comicapi/comicinfoxml.py @@ -19,8 +19,6 @@ import xml.etree.ElementTree as ET from collections import OrderedDict from typing import Any from typing import cast -from typing import List -from typing import Optional from xml.etree.ElementTree import ElementTree from comicapi import utils diff --git a/comicapi/filenamelexer.py b/comicapi/filenamelexer.py index ac5a4b6..5ab35c3 100644 --- a/comicapi/filenamelexer.py +++ b/comicapi/filenamelexer.py @@ -7,8 +7,6 @@ from enum import auto from enum import Enum from typing import Any from typing import Callable -from typing import Optional -from typing import Set class ItemType(Enum): diff --git a/comicapi/filenameparser.py b/comicapi/filenameparser.py index 7268edf..f2878bd 100644 --- a/comicapi/filenameparser.py +++ b/comicapi/filenameparser.py @@ -26,7 +26,6 @@ import re from operator import itemgetter from typing import Callable from typing import Match -from typing import Optional from typing import TypedDict from urllib.parse import unquote @@ -194,9 +193,10 @@ class FileNameParser: volume = "" # save the last word - try: - last_word = series.split()[-1] - except: + split = series.split() + if split: + last_word = split[-1] + else: last_word = "" # remove any parenthetical phrases @@ -225,12 +225,11 @@ class FileNameParser: # be removed to help search online if issue_start == 0: one_shot_words = ["tpb", "os", "one-shot", "ogn", "gn"] - try: - last_word = series.split()[-1] - if last_word.lower() in one_shot_words: - series = series.rsplit(" ", 1)[0] - except: - pass + split = series.split() + if split: + last_word = split[-1] + if last_word.casefold() in one_shot_words: + series, _, _ = series.rpartition(" ") if volume: series = re.sub(r"\s+v(|ol|olume)$", "", series) @@ -432,7 +431,8 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None: if len(item.val.lstrip("0")) < 4: # An issue number starting with # Was not found and no previous number was found if p.issue_number_at is None: - # Series has already been started/parsed, filters out leading alternate numbers leading alternate number + # Series has already been started/parsed, + # filters out leading alternate numbers leading alternate number if len(p.series_parts) > 0: # Unset first item if p.firstItem: @@ -528,7 +528,8 @@ def parse(p: Parser) -> Callable[[Parser], Callable | None] | None: if p.peek_back().typ == filenamelexer.ItemType.Dot: p.used_items.append(p.peek_back()) - # Allows removing DC from 'Wonder Woman 49 DC Sep-Oct 1951' dependent on publisher being in a static list in the lexer + # Allows removing DC from 'Wonder Woman 49 DC Sep-Oct 1951' + # dependent on publisher being in a static list in the lexer elif item.typ == filenamelexer.ItemType.Publisher: p.filename_info["publisher"] = item.val p.used_items.append(item) @@ -867,14 +868,16 @@ def parse_finish(p: Parser) -> Callable[[Parser], Callable | None] | None: if issue_num in [x[1] for x in p.year_candidates]: p.series_parts.append(issue_num) else: - # If this number was rejected because of an operator and the operator is still there add it back e.g. 'IG-88' + # If this number was rejected because of an operator and the operator is still there add it back + # e.g. 'IG-88' if ( issue_num in p.operator_rejected and p.series_parts and p.series_parts[-1].typ == filenamelexer.ItemType.Operator ): p.series_parts.append(issue_num) - # We have no reason to not use this number as the issue number. Specifically happens when parsing 'X-Men-V1-067.cbr' + # We have no reason to not use this number as the issue number. + # Specifically happens when parsing 'X-Men-V1-067.cbr' else: p.filename_info["issue"] = issue_num.val p.used_items.append(issue_num) @@ -1035,7 +1038,8 @@ def parse_info_specifier(p: Parser) -> Callable[[Parser], Callable | None] | Non p.used_items.append(item) p.used_items.append(number) - # This is not for the issue number it is not in either the issue or the title, assume it is the volume number and count + # This is not for the issue number it is not in either the issue or the title, + # assume it is the volume number and count elif p.issue_number_at != i.pos and i not in p.series_parts and i not in p.title_parts: p.filename_info["volume"] = i.val p.filename_info["volume_count"] = str(int(t2do.convert(number.val))) @@ -1060,7 +1064,8 @@ def get_number(p: Parser, index: int) -> filenamelexer.Item | None: rev = p.input[:index] rev.reverse() for i in rev: - # We don't care about these types, we are looking to see if there is a number that is possibly different from the issue number for this count + # We don't care about these types, we are looking to see if there is a number that is possibly different from + # the issue number for this count if i.typ in [ filenamelexer.ItemType.LeftParen, filenamelexer.ItemType.LeftBrace, diff --git a/comicapi/genericmetadata.py b/comicapi/genericmetadata.py index 884aa94..f3f16aa 100644 --- a/comicapi/genericmetadata.py +++ b/comicapi/genericmetadata.py @@ -22,8 +22,6 @@ from __future__ import annotations import logging from typing import Any -from typing import List -from typing import Optional from typing import TypedDict from comicapi import utils @@ -376,9 +374,9 @@ md_test.volume = 1 md_test.genre = "Sci-Fi" md_test.language = "en" md_test.comments = ( - "For 12-year-old Anda, getting paid real money to kill the characters of players who were cheating in her favorite online " - "computer game was a win-win situation. Until she found out who was paying her, and what those characters meant to the " - "livelihood of children around the world." + "For 12-year-old Anda, getting paid real money to kill the characters of players who were cheating" + " in her favorite online computer game was a win-win situation. Until she found out who was paying her," + " and what those characters meant to the livelihood of children around the world." ) md_test.volume_count = None md_test.critical_rating = None diff --git a/comicapi/issuestring.py b/comicapi/issuestring.py index 5c863d2..c468fc8 100644 --- a/comicapi/issuestring.py +++ b/comicapi/issuestring.py @@ -21,7 +21,6 @@ from __future__ import annotations import logging import unicodedata -from typing import Optional logger = logging.getLogger(__name__) diff --git a/comicapi/utils.py b/comicapi/utils.py index 5e8d328..b48c65c 100644 --- a/comicapi/utils.py +++ b/comicapi/utils.py @@ -22,9 +22,7 @@ import re import unicodedata from collections import defaultdict from typing import Any -from typing import List -from typing import Optional -from typing import Union +from typing import Mapping import pycountry @@ -202,7 +200,7 @@ def get_language(string: str | None) -> str | None: if lang is None: try: return str(pycountry.languages.lookup(string).name) - except: + except LookupError: return None return lang @@ -220,7 +218,7 @@ def get_publisher(publisher: str) -> tuple[str, str]: return (imprint, publisher) -def update_publishers(new_publishers: dict[str, dict[str, str]]) -> None: +def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None: for publisher in new_publishers: if publisher in publishers: publishers[publisher].update(new_publishers[publisher]) @@ -236,7 +234,7 @@ class ImprintDict(dict): if the key does not exist the key is returned as the publisher unchanged """ - def __init__(self, publisher, mapping=(), **kwargs): + def __init__(self, publisher: str, mapping=(), **kwargs) -> None: super().__init__(mapping, **kwargs) self.publisher = publisher diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55ec8d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 120 diff --git a/setup.cfg b/setup.cfg index 8ccf4d0..3693c9d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,9 +23,20 @@ classifiers = [options] packages = comicapi install_requires = - natsort>=3.5.2 + natsort>=8.1.0 + pillow>=4.3.0 + py7zr + pycountry + text2digits + typing-extensions + wordninja python_requires = >=3.6 [options.extras_require] cbr = - rarfile==2.7 + unrar-cffi>=0.2.2 + +[flake8] +max-line-length = 120 +extend-ignore = + E203 diff --git a/testing/filenames.py b/testing/filenames.py new file mode 100644 index 0000000..c06bc46 --- /dev/null +++ b/testing/filenames.py @@ -0,0 +1,789 @@ +""" +format is +( + "filename", + "reason or unique case", + { + "expected": "Dictionary of properties extracted from filename", + }, + bool(xfail: expected failure on the old parser) +) +""" +from __future__ import annotations + +fnames = [ + ( + "batman 3 title (DC).cbz", + "honorific and publisher in series", + { + "issue": "3", + "series": "batman", + "title": "title", + "publisher": "DC", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + "alternate": "", + }, + True, + ), + ( + "batman 3 title DC.cbz", + "honorific and publisher in series", + { + "issue": "3", + "series": "batman", + "title": "title DC", + "publisher": "DC", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + "alternate": "", + }, + True, + ), + ( + "ms. Marvel 3.cbz", + "honorific and publisher in series", + { + "issue": "3", + "series": "ms. Marvel", + "title": "", + "publisher": "Marvel", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + "alternate": "", + }, + False, + ), + ( + "january jones 2.cbz", + "month in series", + { + "issue": "2", + "series": "january jones", + "title": "", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + "alternate": "", + }, + False, + ), + ( + "52.cbz", + "issue number only", + { + "issue": "52", + "series": "", + "title": "", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + "alternate": "", + }, + True, + ), + ( + "52 Monster_Island_v1_2__repaired__c2c.cbz", + "leading alternate", + { + "issue": "2", + "series": "Monster Island", + "title": "", + "volume": "1", + "year": "", + "remainder": "repaired", + "issue_count": "", + "alternate": "52", + "c2c": True, + }, + True, + ), + ( + "Monster_Island_v1_2__repaired__c2c.cbz", + "Example from userguide", + { + "issue": "2", + "series": "Monster Island", + "title": "", + "volume": "1", + "year": "", + "remainder": "repaired", + "issue_count": "", + "c2c": True, + }, + False, + ), + ( + "Monster Island v1 3 (1957) -- The Revenge Of King Klong (noads).cbz", + "Example from userguide", + { + "issue": "3", + "series": "Monster Island", + "title": "", + "volume": "1", + "year": "1957", + "remainder": "The Revenge Of King Klong (noads)", + "issue_count": "", + }, + False, + ), + ( + "Foobar-Man Annual 121 - The Wrath of Foobar-Man, Part 1 of 2.cbz", + "Example from userguide", + { + "issue": "121", + "series": "Foobar-Man Annual", + "title": "The Wrath of Foobar-Man, Part 1 of 2", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + "annual": True, + }, + True, + ), + ( + "Plastic Man v1 002 (1942).cbz", + "Example from userguide", + { + "issue": "2", + "series": "Plastic Man", + "title": "", + "volume": "1", + "year": "1942", + "remainder": "", + "issue_count": "", + }, + False, + ), + ( + "Blue Beetle 02.cbr", + "Example from userguide", + { + "issue": "2", + "series": "Blue Beetle", + "title": "", + "volume": "", + "year": "", + "remainder": "", + "issue_count": "", + }, + False, + ), + ( + "Monster Island vol. 2 #2.cbz", + "Example from userguide", + { + "issue": "2", + "series": "Monster Island", + "title": "", + "volume": "2", + "year": "", + "remainder": "", + "issue_count": "", + }, + False, + ), + ( + "Crazy Weird Comics 2 (of 2) (1969).rar", + "Example from userguide", + { + "issue": "2", + "series": "Crazy Weird Comics", + "title": "", + "volume": "", + "year": "1969", + "remainder": "", + "issue_count": "2", + }, + False, + ), + ( + "Super Strange Yarns (1957) #92 (1969).cbz", + "Example from userguide", + { + "issue": "92", + "series": "Super Strange Yarns", + "title": "", + "volume": "1957", + "year": "1969", + "remainder": "", + "issue_count": "", + }, + False, + ), + ( + "Action Spy Tales v1965 #3.cbr", + "Example from userguide", + { + "issue": "3", + "series": "Action Spy Tales", + "title": "", + "volume": "1965", + "year": "", + "remainder": "", + "issue_count": "", + }, + False, + ), + ( + " X-Men-V1-067.cbr", + "hyphen separated with hyphen in series", # only parses corretly because v1 designates the volume + { + "issue": "67", + "series": "X-Men", + "title": "", + "volume": "1", + "year": "", + "remainder": "", + "issue_count": "", + }, + True, + ), + ( + "Amazing Spider-Man 078.BEY (2022) (Digital) (Zone-Empire).cbr", + "number issue with extra", + { + "issue": "78.BEY", + "series": "Amazing Spider-Man", + "title": "", + "volume": "", + "year": "2022", + "remainder": "(Digital) (Zone-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Angel Wings 02 - Black Widow (2015) (Scanlation) (phillywilly).cbr", + "title after issue", + { + "issue": "2", + "series": "Angel Wings", + "title": "Black Widow", + "volume": "", + "year": "2015", + "remainder": "(Scanlation) (phillywilly)", + "issue_count": "", + }, + True, + ), + ( + "Angel Wings #02 - Black Widow (2015) (Scanlation) (phillywilly).cbr", + "title after #issue", + { + "issue": "2", + "series": "Angel Wings", + "title": "Black Widow", + "volume": "", + "year": "2015", + "remainder": "(Scanlation) (phillywilly)", + "issue_count": "", + }, + False, + ), + ( + "Aquaman - Green Arrow - Deep Target 01 (of 07) (2021) (digital) (Son of Ultron-Empire).cbr", + "issue count", + { + "issue": "1", + "series": "Aquaman - Green Arrow - Deep Target", + "title": "", + "volume": "", + "year": "2021", + "issue_count": "7", + "remainder": "(digital) (Son of Ultron-Empire)", + }, + False, + ), + ( + "Aquaman 80th Anniversary 100-Page Super Spectacular (2021) 001 (2021) (Digital) (BlackManta-Empire).cbz", + "numbers in series", + { + "issue": "1", + "series": "Aquaman 80th Anniversary 100-Page Super Spectacular", + "title": "", + "volume": "2021", + "year": "2021", + "remainder": "(Digital) (BlackManta-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Avatar - The Last Airbender - The Legend of Korra (FCBD 2021) (Digital) (mv-DCP).cbr", + "FCBD date", + { + "issue": "", + "series": "Avatar - The Last Airbender - The Legend of Korra", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(Digital) (mv-DCP)", + "issue_count": "", + "fcbd": True, + }, + True, + ), + ( + "Avengers By Brian Michael Bendis volume 03 (2013) (Digital) (F2) (Kileko-Empire).cbz", + "volume without issue", + { + "issue": "3", + "series": "Avengers By Brian Michael Bendis", + "title": "", + "volume": "3", + "year": "2013", + "remainder": "(Digital) (F2) (Kileko-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Avengers By Brian Michael Bendis v03 (2013) (Digital) (F2) (Kileko-Empire).cbz", + "volume without issue", + { + "issue": "3", + "series": "Avengers By Brian Michael Bendis", + "title": "", + "volume": "3", + "year": "2013", + "remainder": "(Digital) (F2) (Kileko-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Batman '89 (2021) (Webrip) (The Last Kryptonian-DCP).cbr", + "year in title without issue", + { + "issue": "", + "series": "Batman '89", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(Webrip) (The Last Kryptonian-DCP)", + "issue_count": "", + }, + False, + ), + ( + "Batman_-_Superman_020_(2021)_(digital)_(NeverAngel-Empire).cbr", + "underscores", + { + "issue": "20", + "series": "Batman - Superman", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(digital) (NeverAngel-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Black Widow 009 (2021) (Digital) (Zone-Empire).cbr", + "standard", + { + "issue": "9", + "series": "Black Widow", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(Digital) (Zone-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Blade Runner 2029 006 (2021) (3 covers) (digital) (Son of Ultron-Empire).cbr", + "year before issue", + { + "issue": "6", + "series": "Blade Runner 2029", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(3 covers) (digital) (Son of Ultron-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Blade Runner Free Comic Book Day 2021 (2021) (digital-Empire).cbr", + "FCBD year and (year)", + { + "issue": "", + "series": "Blade Runner Free Comic Book Day 2021", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(digital-Empire)", + "issue_count": "", + "fcbd": True, + }, + True, + ), + ( + "Bloodshot Book 03 (2020) (digital) (Son of Ultron-Empire).cbr", + "book", + { + "issue": "3", + "series": "Bloodshot", + "title": "Book 03", + "volume": "3", + "year": "2020", + "remainder": "(digital) (Son of Ultron-Empire)", + "issue_count": "", + }, + True, + ), + ( + "book of eli (2020) (digital) (Son of Ultron-Empire).cbr", + "book", + { + "issue": "", + "series": "book of eli", + "title": "", + "volume": "", + "year": "2020", + "remainder": "(digital) (Son of Ultron-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Cyberpunk 2077 - You Have My Word 02 (2021) (digital) (Son of Ultron-Empire).cbr", + "title", + { + "issue": "2", + "series": "Cyberpunk 2077", + "title": "You Have My Word", + "volume": "", + "year": "2021", + "issue_count": "", + "remainder": "(digital) (Son of Ultron-Empire)", + }, + True, + ), + ( + "Elephantmen 2259 008 - Simple Truth 03 (of 06) (2021) (digital) (Son of Ultron-Empire).cbr", + "volume count", + { + "issue": "8", + "series": "Elephantmen 2259", + "title": "Simple Truth", + "volume": "3", + "year": "2021", + "volume_count": "6", + "remainder": "(digital) (Son of Ultron-Empire)", + "issue_count": "", + }, + True, + ), + ( + "Elephantmen 2259 #008 - Simple Truth 03 (of 06) (2021) (digital) (Son of Ultron-Empire).cbr", + "volume count", + { + "issue": "8", + "series": "Elephantmen 2259", + "title": "Simple Truth", + "volume": "3", + "year": "2021", + "volume_count": "6", + "remainder": "(digital) (Son of Ultron-Empire)", + "issue_count": "", + }, + True, + ), + ( + "Free Comic Book Day - Avengers.Hulk (2021) (2048px) (db).cbz", + "'.' in name", + { + "issue": "", + "series": "Free Comic Book Day - Avengers Hulk", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(2048px) (db)", + "issue_count": "", + "fcbd": True, + }, + True, + ), + ( + "Goblin (2021) (digital) (Son of Ultron-Empire).cbr", + "no-issue", + { + "issue": "", + "series": "Goblin", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(digital) (Son of Ultron-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Marvel Previews 002 (January 2022) (Digital-Empire).cbr", + "(month year)", + { + "issue": "2", + "series": "Marvel Previews", + "title": "", + "publisher": "Marvel", + "volume": "", + "year": "2022", + "remainder": "(Digital-Empire)", + "issue_count": "", + }, + True, + ), + ( + "Marvel Two In One V1 090 c2c (Comixbear-DCP).cbr", + "volume issue ctc", + { + "issue": "90", + "series": "Marvel Two In One", + "title": "", + "publisher": "Marvel", + "volume": "1", + "year": "", + "remainder": "(Comixbear-DCP)", + "issue_count": "", + "c2c": True, + }, + True, + ), + ( + "Marvel Two In One V1 #090 c2c (Comixbear-DCP).cbr", + "volume then issue", + { + "issue": "90", + "series": "Marvel Two In One", + "title": "", + "publisher": "Marvel", + "volume": "1", + "year": "", + "remainder": "(Comixbear-DCP)", + "issue_count": "", + "c2c": True, + }, + False, + ), + ( + "Star Wars - War of the Bounty Hunters - IG-88 (2021) (Digital) (Kileko-Empire).cbz", + "number ends series, no-issue", + { + "issue": "", + "series": "Star Wars - War of the Bounty Hunters - IG-88", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(Digital) (Kileko-Empire)", + "issue_count": "", + }, + True, + ), + ( + "Star Wars - War of the Bounty Hunters - IG-88 #1 (2021) (Digital) (Kileko-Empire).cbz", + "number ends series", + { + "issue": "1", + "series": "Star Wars - War of the Bounty Hunters - IG-88", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(Digital) (Kileko-Empire)", + "issue_count": "", + }, + False, + ), + ( + "The Defenders v1 058 (1978) (digital).cbz", + "", + { + "issue": "58", + "series": "The Defenders", + "title": "", + "volume": "1", + "year": "1978", + "remainder": "(digital)", + "issue_count": "", + }, + False, + ), + ( + "The Defenders v1 Annual 01 (1976) (Digital) (Minutemen-Slayer).cbr", + " v in series", + { + "issue": "1", + "series": "The Defenders Annual", + "title": "", + "volume": "1", + "year": "1976", + "remainder": "(Digital) (Minutemen-Slayer)", + "issue_count": "", + "annual": True, + }, + True, + ), + ( + "The Magic Order 2 06 (2022) (Digital) (Zone-Empire)[__913302__].cbz", + "ending id", + { + "issue": "6", + "series": "The Magic Order 2", + "title": "", + "volume": "", + "year": "2022", + "remainder": "(Digital) (Zone-Empire)[913302]", # Don't really care about double underscores + "issue_count": "", + }, + False, + ), + ( + "Wonder Woman 001 Wonder Woman Day Special Edition (2021) (digital-Empire).cbr", + "issue separates title", + { + "issue": "1", + "series": "Wonder Woman", + "title": "Wonder Woman Day Special Edition", + "volume": "", + "year": "2021", + "remainder": "(digital-Empire)", + "issue_count": "", + }, + True, + ), + ( + "Wonder Woman #001 Wonder Woman Day Special Edition (2021) (digital-Empire).cbr", + "issue separates title", + { + "issue": "1", + "series": "Wonder Woman", + "title": "Wonder Woman Day Special Edition", + "volume": "", + "year": "2021", + "remainder": "(digital-Empire)", + "issue_count": "", + }, + False, + ), + ( + "Wonder Woman 49 DC Sep-Oct 1951 digital [downsized, lightened, 4 missing story pages restored] (Shadowcat-Empire).cbz", + "date-range, no paren, braces", + { + "issue": "49", + "series": "Wonder Woman", + "title": "digital", # Don't have a way to get rid of this + "publisher": "DC", + "volume": "", + "year": "1951", + "remainder": "[downsized, lightened, 4 missing story pages restored] (Shadowcat-Empire)", + "issue_count": "", + }, + True, + ), + ( + "Wonder Woman #49 DC Sep-Oct 1951 digital [downsized, lightened, 4 missing story pages restored] (Shadowcat-Empire).cbz", + "date-range, no paren, braces", + { + "issue": "49", + "series": "Wonder Woman", + "title": "digital", # Don't have a way to get rid of this + "publisher": "DC", + "volume": "", + "year": "1951", + "remainder": "[downsized, lightened, 4 missing story pages restored] (Shadowcat-Empire)", + "issue_count": "", + }, + True, + ), + ( + "X-Men, 2021-08-04 (#02) (digital) (Glorith-HD).cbz", + "full-date, issue in parenthesis", + { + "issue": "2", + "series": "X-Men", + "title": "", + "volume": "", + "year": "2021", + "remainder": "(digital) (Glorith-HD)", + "issue_count": "", + }, + True, + ), +] + +rnames = [ + ( + "{series} #{issue} - {title} ({year}) ({price})", # price should be none + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + ), + ( + "{series} #{issue} - {title} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + ), + ( + "{series}: {title} #{issue} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now - Anda's Game #001 (2007).cbz", + ), + ( + "{series}: {title} #{issue} ({year})", + False, + "Linux", + "Cory Doctorow's Futuristic Tales of the Here and Now: Anda's Game #001 (2007).cbz", + ), + ( + "{publisher}/ {series} #{issue} - {title} ({year})", + True, + "universal", + "IDW Publishing/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + ), + ( + "{publisher}/ {series} #{issue} - {title} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + ), + ( + r"{publisher}\ {series} #{issue} - {title} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz", + ), + ( + "{series} # {issue} - {title} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now # 001 - Anda's Game (2007).cbz", + ), + ( + "{series} # {issue} - {locations} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now # 001 - lonely cottage (2007).cbz", + ), + ( + "{series} #{issue} - {title} - {WriteR}, {EDITOR} ({year})", + False, + "universal", + "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game - Dara Naraghi, Ted Adams (2007).cbz", + ), +] diff --git a/tests/autoimprint_test.py b/tests/autoimprint_test.py new file mode 100644 index 0000000..b02281f --- /dev/null +++ b/tests/autoimprint_test.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Mapping + +import pytest + +from comicapi import utils + +imprints = [ + ("marvel", ("", "Marvel")), + ("marvel comics", ("", "Marvel")), + ("aircel", ("Aircel Comics", "Marvel")), +] + +additional_imprints = [ + ("test", ("Test", "Marvel")), + ("temp", ("Temp", "DC Comics")), +] + +all_imprints = imprints + additional_imprints + +seed: Mapping[str, utils.ImprintDict] = { + "Marvel": utils.ImprintDict( + "Marvel", + { + "marvel comics": "", + "aircel": "Aircel Comics", + }, + ) +} + +additional_seed: Mapping[str, utils.ImprintDict] = { + "Marvel": utils.ImprintDict("Marvel", {"test": "Test"}), + "DC Comics": utils.ImprintDict("DC Comics", {"temp": "Temp"}), +} + +all_seed: Mapping[str, utils.ImprintDict] = { + "Marvel": seed["Marvel"].copy(), + "DC Comics": additional_seed["DC Comics"].copy(), +} +all_seed["Marvel"].update(additional_seed["Marvel"]) + +conflicting_seed = {"Marvel": {"test": "Never"}} + + +# manually seeds publishers +@pytest.fixture +def seed_publishers(monkeypatch): + publisher_seed = {} + for publisher, imprint in seed.items(): + publisher_seed[publisher] = imprint + monkeypatch.setattr(utils, "publishers", publisher_seed) + + +@pytest.fixture +def seed_all_publishers(monkeypatch): + publisher_seed = {} + for publisher, imprint in all_seed.items(): + publisher_seed[publisher] = imprint + monkeypatch.setattr(utils, "publishers", publisher_seed) + + +# test that that an empty list returns the input unchanged +@pytest.mark.parametrize("publisher, expected", imprints) +def test_get_publisher_empty(publisher: str, expected: tuple[str, str]): + assert ("", publisher) == utils.get_publisher(publisher) + + +# initial test +@pytest.mark.parametrize("publisher, expected", imprints) +def test_get_publisher(publisher: str, expected: tuple[str, str], seed_publishers): + assert expected == utils.get_publisher(publisher) + + +# tests that update_publishers will initially set values +@pytest.mark.parametrize("publisher, expected", imprints) +def test_set_publisher(publisher: str, expected: tuple[str, str]): + utils.update_publishers(seed) + assert expected == utils.get_publisher(publisher) + + +# tests that update_publishers will add to existing values +@pytest.mark.parametrize("publisher, expected", all_imprints) +def test_update_publisher(publisher: str, expected: tuple[str, str], seed_publishers): + utils.update_publishers(additional_seed) + assert expected == utils.get_publisher(publisher) + + +# tests that update_publishers will overwrite conflicting existing values +def test_conflict_publisher(seed_all_publishers): + assert ("Test", "Marvel") == utils.get_publisher("test") + + utils.update_publishers(conflicting_seed) + + assert ("Never", "Marvel") == utils.get_publisher("test") diff --git a/tests/comicarchive_test.py b/tests/comicarchive_test.py new file mode 100644 index 0000000..046d163 --- /dev/null +++ b/tests/comicarchive_test.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import pathlib +import shutil + +import pytest + +import comicapi.comicarchive +import comicapi.genericmetadata + +thisdir = pathlib.Path(__file__).parent +cbz_path = thisdir / "data" / "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz" + + +@pytest.mark.xfail(not comicapi.comicarchive.rar_support, reason="rar support") +def test_getPageNameList(): + c = comicapi.comicarchive.ComicArchive(thisdir / "data" / "fake_cbr.cbr") + pageNameList = c.get_page_name_list() + + assert pageNameList == [ + "page0.jpg", + "Page1.jpeg", + "Page2.png", + "Page3.gif", + "page4.webp", + "page10.jpg", + ] + + +def test_set_default_page_list(tmpdir): + md = comicapi.genericmetadata.GenericMetadata() + md.overlay(comicapi.genericmetadata.md_test) + md.pages = [] + md.set_default_page_list(len(comicapi.genericmetadata.md_test.pages)) + + assert isinstance(md.pages[0]["Image"], int) + + +def test_page_type_read(): + c = comicapi.comicarchive.ComicArchive(cbz_path) + md = c.read_cix() + + assert isinstance(md.pages[0]["Type"], str) + + +def test_metadata_read(): + c = comicapi.comicarchive.ComicArchive( + thisdir / "data" / "Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz" + ) + md = c.read_cix() + md_dict = md.__dict__ + md_test_dict = comicapi.genericmetadata.md_test.__dict__ + assert md_dict == md_test_dict + + +def test_save_cix(tmpdir): + comic_path = tmpdir.mkdir("cbz") / cbz_path.name + print(comic_path) + shutil.copy(cbz_path, comic_path) + + c = comicapi.comicarchive.ComicArchive(comic_path) + md = c.read_cix() + md.set_default_page_list(c.get_number_of_pages()) + + assert c.write_cix(md) + + md = c.read_cix() + + +def test_page_type_save(tmpdir): + comic_path = tmpdir.mkdir("cbz") / cbz_path.name + print(comic_path) + + shutil.copy(cbz_path, comic_path) + + c = comicapi.comicarchive.ComicArchive(comic_path) + md = c.read_cix() + t = md.pages[0] + t["Type"] = "" + + assert c.write_cix(md) + + md = c.read_cix() + + +archivers = [ + comicapi.comicarchive.ZipArchiver, + comicapi.comicarchive.TarArchiver, + comicapi.comicarchive.SevenZipArchiver, + pytest.param( + comicapi.comicarchive.RarArchiver, + marks=pytest.mark.xfail(not comicapi.comicarchive.rar_support, reason="rar support"), + ), +] + + +@pytest.mark.parametrize("archiver", archivers) +def test_copy_to_archive(archiver, tmpdir): + comic_path = tmpdir / cbz_path.with_suffix("").name + + cbz = comicapi.comicarchive.ZipArchiver(cbz_path) + archive = archiver(comic_path) + + assert archive.copy_from_archive(cbz) + + comic_archive = comicapi.comicarchive.ComicArchive(comic_path) + + md = comic_archive.read_cix() + md_dict = md.__dict__ + md_test_dict = comicapi.genericmetadata.md_test.__dict__ + assert md_dict == md_test_dict + + md = comicapi.genericmetadata.GenericMetadata() + md.overlay(comicapi.genericmetadata.md_test) + md.series = "test" + + assert comic_archive.write_cix(md) + + test_md = comic_archive.read_cix() + md_dict = md.__dict__ + test_md_dict = test_md.__dict__ + assert md_dict == test_md_dict diff --git a/tests/data/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbt b/tests/data/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbt new file mode 100644 index 0000000..9212a9e Binary files /dev/null and b/tests/data/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbt differ diff --git a/tests/data/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz b/tests/data/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz new file mode 100644 index 0000000..a228fd0 Binary files /dev/null and b/tests/data/Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz differ diff --git a/tests/data/fake_cbr.cbr b/tests/data/fake_cbr.cbr new file mode 100644 index 0000000..1157aec Binary files /dev/null and b/tests/data/fake_cbr.cbr differ diff --git a/tests/filenameparser_test.py b/tests/filenameparser_test.py new file mode 100644 index 0000000..06f9305 --- /dev/null +++ b/tests/filenameparser_test.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +import comicapi.filenameparser +from testing.filenames import fnames + + +@pytest.mark.parametrize("filename, reason, expected, xfail", fnames) +def test_file_name_parser_new(filename, reason, expected, xfail): + p = comicapi.filenameparser.Parse( + comicapi.filenamelexer.Lex(filename).items, + first_is_alt=True, + remove_c2c=True, + remove_fcbd=True, + remove_publisher=True, + ) + fp = p.filename_info + + for s in ["archive"]: + if s in fp: + del fp[s] + for s in ["alternate", "publisher", "volume_count"]: + if s not in expected: + expected[s] = "" + for s in ["fcbd", "c2c", "annual"]: + if s not in expected: + expected[s] = False + + assert fp == expected + + +@pytest.mark.parametrize("filename, reason, expected, xfail", fnames) +def test_file_name_parser(filename, reason, expected, xfail): + p = comicapi.filenameparser.FileNameParser() + p.parse_filename(filename) + fp = p.__dict__ + # These are currently not tracked in this parser + for s in ["title", "alternate", "publisher", "fcbd", "c2c", "annual", "volume_count", "remainder"]: + if s in expected: + del expected[s] + + # The remainder is not considered compatible between parsers + if "remainder" in fp: + del fp["remainder"] + + if xfail and fp != expected: + pytest.xfail("old parser") + assert fp == expected diff --git a/tests/issuestring_test.py b/tests/issuestring_test.py new file mode 100644 index 0000000..49b671e --- /dev/null +++ b/tests/issuestring_test.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import pytest + +import comicapi.issuestring + +issues = [ + ("¼", 0.25, "¼"), + ("1½", 1.5, "001½"), + ("0.5", 0.5, "000.5"), + ("0", 0.0, "000"), + ("1", 1.0, "001"), + ("22.BEY", 22.0, "022.BEY"), + ("22A", 22.0, "022A"), + ("22-A", 22.0, "022-A"), +] + + +@pytest.mark.parametrize("issue, expected_float, expected_pad_str", issues) +def test_issue_string_as_float(issue, expected_float, expected_pad_str): + issue_float = comicapi.issuestring.IssueString(issue).as_float() + assert issue_float == expected_float + + +@pytest.mark.parametrize("issue, expected_float, expected_pad_str", issues) +def test_issue_string_as_string(issue, expected_float, expected_pad_str): + issue_str = comicapi.issuestring.IssueString(issue).as_string() + issue_str_pad = comicapi.issuestring.IssueString(issue).as_string(3) + assert issue_str == issue + assert issue_str_pad == expected_pad_str