This commit is contained in:
lordwelch 2022-05-31 15:46:12 -07:00
parent 61d0db6257
commit 8a9935e1a9
20 changed files with 1338 additions and 221 deletions

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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,

View File

@ -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

View File

@ -21,7 +21,6 @@ from __future__ import annotations
import logging
import unicodedata
from typing import Optional
logger = logging.getLogger(__name__)

View File

@ -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

2
pyproject.toml Normal file
View File

@ -0,0 +1,2 @@
[tool.black]
line-length = 120

View File

@ -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

789
testing/filenames.py Normal file
View File

@ -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",
),
]

95
tests/autoimprint_test.py Normal file
View File

@ -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")

122
tests/comicarchive_test.py Normal file
View File

@ -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

BIN
tests/data/fake_cbr.cbr Normal file

Binary file not shown.

View File

@ -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

30
tests/issuestring_test.py Normal file
View File

@ -0,0 +1,30 @@
from __future__ import annotations
import pytest
import comicapi.issuestring
issues = [
("¼", 0.25, "¼"),
("", 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