diff --git a/comicapi/comet.py b/comicapi/comet.py
index d3afe3f..138d602 100644
--- a/comicapi/comet.py
+++ b/comicapi/comet.py
@@ -91,7 +91,7 @@ class CoMet:
date_str += f"-{md.month:02}"
assign("date", date_str)
- assign("coverImage", md.cover_image)
+ assign("coverImage", md._cover_image)
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
@@ -156,7 +156,7 @@ class CoMet:
_, md.month, md.year = utils.parse_date_str(utils.xlate(get("date")))
- md.cover_image = utils.xlate(get("coverImage"))
+ md._cover_image = utils.xlate(get("coverImage"))
reading_direction = utils.xlate(get("readingDirection"))
if reading_direction is not None and reading_direction == "rtl":
diff --git a/comicapi/comicarchive.py b/comicapi/comicarchive.py
index 8a47a82..54e1624 100644
--- a/comicapi/comicarchive.py
+++ b/comicapi/comicarchive.py
@@ -45,11 +45,10 @@ def load_archive_plugins() -> None:
for arch in entry_points(group="comicapi.archiver"):
try:
archiver: type[Archiver] = arch.load()
- if archiver.enabled:
- if arch.module.startswith("comicapi"):
- builtin.append(archiver)
- else:
- archivers.append(archiver)
+ if arch.module.startswith("comicapi"):
+ builtin.append(archiver)
+ else:
+ archivers.append(archiver)
except Exception:
logger.warning("Failed to load talker: %s", arch.name)
archivers.extend(builtin)
@@ -88,7 +87,7 @@ class ComicArchive:
load_archive_plugins()
for archiver in archivers:
- if archiver.is_valid(self.path):
+ if archiver.enabled and archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
break
@@ -425,10 +424,10 @@ class ComicArchive:
# use the coverImage value from the comet_data to mark the cover in this struct
# walk through list of images in file, and find the matching one for md.coverImage
# need to remove the existing one in the default
- if self.comet_md.cover_image is not None:
+ if self.comet_md._cover_image is not None:
cover_idx = 0
for idx, f in enumerate(self.get_page_name_list()):
- if self.comet_md.cover_image == f:
+ if self.comet_md._cover_image == f:
cover_idx = idx
break
if cover_idx != 0:
@@ -462,7 +461,7 @@ class ComicArchive:
# Set the coverImage value, if it's not the first page
cover_idx = int(metadata.get_cover_page_index_list()[0])
if cover_idx != 0:
- metadata.cover_image = self.get_page_name(cover_idx)
+ metadata._cover_image = self.get_page_name(cover_idx)
comet_string = CoMet().string_from_metadata(metadata)
write_success = self.archiver.write_file(cast(str, self.comet_filename), comet_string.encode("utf-8"))
diff --git a/comicapi/genericmetadata.py b/comicapi/genericmetadata.py
index 5833d4a..1a45349 100644
--- a/comicapi/genericmetadata.py
+++ b/comicapi/genericmetadata.py
@@ -109,46 +109,44 @@ class GenericMetadata:
series: str | None = None
series_aliases: set[str] = dataclasses.field(default_factory=set)
issue: str | None = None
+ issue_count: int | None = None
title: str | None = None
title_aliases: set[str] = dataclasses.field(default_factory=set)
- publisher: str | None = None
- month: int | None = None
- year: int | None = None
- day: int | None = None
- issue_count: int | None = None
volume: int | None = None
- genres: set[str] = dataclasses.field(default_factory=set)
- language: str | None = None # 2 letter iso code
- description: str | None = None # use same way as Summary in CIX
-
volume_count: int | None = None
- critical_rating: float | None = None # rating in CBL; CommunityRating in CIX
- country: str | None = None
+ genres: set[str] = dataclasses.field(default_factory=set)
+ description: str | None = None # use same way as Summary in CIX
+ notes: str | None = None
alternate_series: str | None = None
alternate_number: str | None = None
alternate_count: int | None = None
+ story_arcs: list[str] = dataclasses.field(default_factory=list)
+ series_groups: list[str] = dataclasses.field(default_factory=list)
+
+ publisher: str | None = None
imprint: str | None = None
- notes: str | None = None
+ day: int | None = None
+ month: int | None = None
+ year: int | None = None
+ language: str | None = None # 2 letter iso code
+ country: str | None = None
web_link: str | None = None
format: str | None = None
manga: str | None = None
black_and_white: bool | None = None
- page_count: int | None = None
maturity_rating: str | None = None
-
- story_arcs: list[str] = dataclasses.field(default_factory=list)
- series_groups: list[str] = dataclasses.field(default_factory=list)
+ critical_rating: float | None = None # rating in CBL; CommunityRating in CIX
scan_info: str | None = None
+ tags: set[str] = dataclasses.field(default_factory=set)
+ pages: list[ImageMetadata] = dataclasses.field(default_factory=list)
+ page_count: int | None = None
+
characters: set[str] = dataclasses.field(default_factory=set)
teams: set[str] = dataclasses.field(default_factory=set)
locations: set[str] = dataclasses.field(default_factory=set)
-
- alternate_images: list[str] = dataclasses.field(default_factory=list)
credits: list[Credit] = dataclasses.field(default_factory=list)
- tags: set[str] = dataclasses.field(default_factory=set)
- pages: list[ImageMetadata] = dataclasses.field(default_factory=list)
# Some CoMet-only items
price: float | None = None
@@ -156,7 +154,10 @@ class GenericMetadata:
rights: str | None = None
identifier: str | None = None
last_mark: str | None = None
- cover_image: str | None = None # url to cover image
+
+ # urls to cover image, not generally part of the metadata
+ _cover_image: str | None = None
+ _alternate_images: list[str] = dataclasses.field(default_factory=list)
def __post_init__(self) -> None:
for key, value in self.__dict__.items():
@@ -191,58 +192,59 @@ class GenericMetadata:
if not new_md.is_empty:
self.is_empty = False
- assign("series", new_md.series)
- assign("series_id", new_md.series_id)
- assign("issue", new_md.issue)
+ assign("tag_origin", new_md.tag_origin)
assign("issue_id", new_md.issue_id)
+ assign("series_id", new_md.series_id)
+
+ assign("series", new_md.series)
+ assign("series_aliases", new_md.series_aliases)
+ assign("issue", new_md.issue)
assign("issue_count", new_md.issue_count)
assign("title", new_md.title)
- assign("publisher", new_md.publisher)
- assign("day", new_md.day)
- assign("month", new_md.month)
- assign("year", new_md.year)
+ assign("title_aliases", new_md.title_aliases)
assign("volume", new_md.volume)
assign("volume_count", new_md.volume_count)
- assign("language", new_md.language)
- assign("country", new_md.country)
- assign("critical_rating", new_md.critical_rating)
+ assign("genres", new_md.genres)
+ assign("description", new_md.description)
+ assign("notes", new_md.notes)
+
assign("alternate_series", new_md.alternate_series)
assign("alternate_number", new_md.alternate_number)
assign("alternate_count", new_md.alternate_count)
+ assign("story_arcs", new_md.story_arcs)
+ assign("series_groups", new_md.series_groups)
+
+ assign("publisher", new_md.publisher)
assign("imprint", new_md.imprint)
+ assign("day", new_md.day)
+ assign("month", new_md.month)
+ assign("year", new_md.year)
+ assign("language", new_md.language)
+ assign("country", new_md.country)
assign("web_link", new_md.web_link)
assign("format", new_md.format)
assign("manga", new_md.manga)
assign("black_and_white", new_md.black_and_white)
assign("maturity_rating", new_md.maturity_rating)
+ assign("critical_rating", new_md.critical_rating)
assign("scan_info", new_md.scan_info)
- assign("description", new_md.description)
- assign("notes", new_md.notes)
+
+ assign("tags", new_md.tags)
+ assign("pages", new_md.pages)
+ assign("page_count", new_md.page_count)
+
+ assign("characters", new_md.characters)
+ assign("teams", new_md.teams)
+ assign("locations", new_md.locations)
+ self.overlay_credits(new_md.credits)
assign("price", new_md.price)
assign("is_version_of", new_md.is_version_of)
assign("rights", new_md.rights)
assign("identifier", new_md.identifier)
assign("last_mark", new_md.last_mark)
-
- self.overlay_credits(new_md.credits)
- # TODO
-
- # not sure if the tags and pages should broken down, or treated
- # as whole lists....
-
- # For now, go the easy route, where any overlay
- # value wipes out the whole list
- assign("series_aliases", new_md.series_aliases)
- assign("title_aliases", new_md.title_aliases)
- assign("genres", new_md.genres)
- assign("story_arcs", new_md.story_arcs)
- assign("series_groups", new_md.series_groups)
- assign("characters", new_md.characters)
- assign("teams", new_md.teams)
- assign("locations", new_md.locations)
- assign("tags", new_md.tags)
- assign("pages", new_md.pages)
+ assign("_cover_image", new_md._cover_image)
+ assign("_alternate_images", new_md._alternate_images)
def overlay_credits(self, new_credits: list[Credit]) -> None:
for c in new_credits:
@@ -494,5 +496,5 @@ md_test: GenericMetadata = GenericMetadata(
rights=None,
identifier=None,
last_mark=None,
- cover_image=None,
+ _cover_image=None,
)
diff --git a/comicapi/utils.py b/comicapi/utils.py
index 0cae5df..56e8c85 100644
--- a/comicapi/utils.py
+++ b/comicapi/utils.py
@@ -153,6 +153,48 @@ def parse_date_str(date_str: str | None) -> tuple[int | None, int | None, int |
return day, month, year
+def shorten_path(path: pathlib.Path, path2: pathlib.Path | None = None) -> tuple[pathlib.Path, pathlib.Path]:
+ if path2:
+ path2 = path2.absolute()
+
+ path = path.absolute()
+ shortened_path: pathlib.Path = path
+ relative_path = pathlib.Path(path.anchor)
+
+ if path.is_relative_to(path.home()):
+ relative_path = path.home()
+ shortened_path = path.relative_to(path.home())
+ if path.is_relative_to(path.cwd()):
+ relative_path = path.cwd()
+ shortened_path = path.relative_to(path.cwd())
+
+ if path2 and shortened_path.is_relative_to(path2.parent):
+ relative_path = path2
+ shortened_path = shortened_path.relative_to(path2)
+
+ return relative_path, shortened_path
+
+
+def path_to_short_str(original_path: pathlib.Path, renamed_path: pathlib.Path | None = None) -> str:
+ rel, _original_path = shorten_path(original_path)
+ path_str = str(_original_path)
+ if rel.samefile(rel.cwd()):
+ path_str = f"./{_original_path}"
+ elif rel.samefile(rel.home()):
+ path_str = f"~/{_original_path}"
+
+ if renamed_path:
+ rel, path = shorten_path(renamed_path, original_path.parent)
+ rename_str = f" -> {path}"
+ if rel.samefile(rel.cwd()):
+ rename_str = f" -> ./{_original_path}"
+ elif rel.samefile(rel.home()):
+ rename_str = f" -> ~/{_original_path}"
+ path_str += rename_str
+
+ return path_str
+
+
def get_recursive_filelist(pathlist: list[str]) -> list[str]:
"""Get a recursive list of of all files under all path items in the list"""
@@ -301,6 +343,14 @@ def unique_file(file_name: pathlib.Path) -> pathlib.Path:
counter += 1
+def parse_version(s: str) -> tuple[int, int, int]:
+ str_parts = s.split(".")[:3]
+ parts = [int(x) if x.isdigit() else 0 for x in str_parts]
+ parts.extend([0] * (3 - len(parts))) # Ensure exactly three elements in the resulting list
+
+ return (parts[0], parts[1], parts[2])
+
+
_languages: dict[str | None, str | None] = defaultdict(lambda: None)
_countries: dict[str | None, str | None] = defaultdict(lambda: None)
diff --git a/comictaggerlib/applicationlogwindow.py b/comictaggerlib/applicationlogwindow.py
index 7505595..e216bd5 100644
--- a/comictaggerlib/applicationlogwindow.py
+++ b/comictaggerlib/applicationlogwindow.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+import pathlib
from PyQt5 import QtCore, QtGui, QtWidgets, uic
@@ -23,9 +24,11 @@ class QTextEditLogger(QtCore.QObject, logging.Handler):
class ApplicationLogWindow(QtWidgets.QDialog):
- def __init__(self, log_handler: QTextEditLogger, parent: QtCore.QObject | None = None) -> None:
+ def __init__(
+ self, log_folder: pathlib.Path, log_handler: QTextEditLogger, parent: QtCore.QObject | None = None
+ ) -> None:
super().__init__(parent)
- with (ui_path / "logwindow.ui").open(encoding="utf-8") as uifile:
+ with (ui_path / "applicationlogwindow.ui").open(encoding="utf-8") as uifile:
uic.loadUi(uifile, self)
self.log_handler = log_handler
@@ -37,6 +40,9 @@ class ApplicationLogWindow(QtWidgets.QDialog):
self._button = QtWidgets.QPushButton(self)
self._button.setText("Test Me")
+ self.log_folder = log_folder
+ self.lblLogLocation.setText(f'Log Location: {log_folder}')
+
layout = self.layout()
layout.addWidget(self._button)
diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py
index 6e1b9f3..96b4723 100644
--- a/comictaggerlib/autotagmatchwindow.py
+++ b/comictaggerlib/autotagmatchwindow.py
@@ -21,11 +21,11 @@ from typing import Callable
from PyQt5 import QtCore, QtGui, QtWidgets, uic
-from comicapi.comicarchive import MetaDataStyle
+from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns
-from comictaggerlib.resulttypes import IssueResult, MultipleMatch
+from comictaggerlib.resulttypes import IssueResult, Result
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker
@@ -37,7 +37,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
- match_set_list: list[MultipleMatch],
+ match_set_list: list[Result],
style: int,
fetch_func: Callable[[IssueResult], GenericMetadata],
config: ct_ns,
@@ -50,7 +50,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.config = config
- self.current_match_set: MultipleMatch = match_set_list[0]
+ self.current_match_set: Result = match_set_list[0]
self.altCoverWidget = CoverImageWidget(
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.Runtime_Options__config.user_cache_dir, talker
@@ -103,7 +103,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.twList.resizeColumnsToContents()
self.twList.selectRow(0)
- path = self.current_match_set.ca.path
+ path = self.current_match_set.original_path
self.setWindowTitle(
"Select correct match or skip ({} of {}): {}".format(
self.current_match_set_idx + 1,
@@ -120,18 +120,18 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.twList.setSortingEnabled(False)
- for row, match in enumerate(self.current_match_set.matches):
+ for row, match in enumerate(self.current_match_set.online_results):
self.twList.insertRow(row)
- item_text = match["series"]
+ item_text = match.series
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
- if match["publisher"] is not None:
- item_text = str(match["publisher"])
+ if match.publisher is not None:
+ item_text = str(match.publisher)
else:
item_text = "Unknown"
item = QtWidgets.QTableWidgetItem(item_text)
@@ -141,10 +141,10 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
month_str = ""
year_str = "????"
- if match["month"] is not None:
- month_str = f"-{int(match['month']):02d}"
- if match["year"] is not None:
- year_str = str(match["year"])
+ if match.month is not None:
+ month_str = f"-{int(match.month):02d}"
+ if match.year is not None:
+ year_str = str(match.year)
item_text = year_str + month_str
item = QtWidgets.QTableWidgetItem(item_text)
@@ -152,7 +152,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
- item_text = match["issue_title"]
+ item_text = match.issue_title
if item_text is None:
item_text = ""
item = QtWidgets.QTableWidgetItem(item_text)
@@ -176,17 +176,15 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
if prev is not None and prev.row() == curr.row():
return None
- self.altCoverWidget.set_issue_details(
- self.current_match()["issue_id"],
- [self.current_match()["image_url"], *self.current_match()["alt_image_urls"]],
- )
- if self.current_match()["description"] is None:
+ match = self.current_match()
+ self.altCoverWidget.set_issue_details(match.issue_id, [match.image_url, *match.alt_image_urls])
+ if match.description is None:
self.teDescription.setText("")
else:
- self.teDescription.setText(self.current_match()["description"])
+ self.teDescription.setText(match.description)
def set_cover_image(self) -> None:
- ca = self.current_match_set.ca
+ ca = ComicArchive(self.current_match_set.original_path)
self.archiveCoverWidget.set_archive(ca)
def current_match(self) -> IssueResult:
@@ -229,7 +227,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
def save_match(self) -> None:
match = self.current_match()
- ca = self.current_match_set.ca
+ ca = ComicArchive(self.current_match_set.original_path)
md = ca.read_metadata(self._style)
if md.is_empty:
@@ -241,7 +239,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
)
# now get the particular issue data
- ct_md = self.fetch_func(match)
+ self.current_match_set.md = ct_md = self.fetch_func(match)
if ct_md is None:
QtWidgets.QMessageBox.critical(self, "Network Issue", "Could not retrieve issue details!")
return
diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py
index 1c2ff19..daa2e30 100644
--- a/comictaggerlib/cli.py
+++ b/comictaggerlib/cli.py
@@ -16,10 +16,16 @@
# limitations under the License.
from __future__ import annotations
+import dataclasses
+import functools
+import json
import logging
import os
+import pathlib
import sys
+from collections.abc import Collection
from datetime import datetime
+from typing import Any, TextIO
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
@@ -30,18 +36,32 @@ from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.graphics import graphics_path
from comictaggerlib.issueidentifier import IssueIdentifier
-from comictaggerlib.resulttypes import MultipleMatch, OnlineMatchResults
+from comictaggerlib.resulttypes import Action, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
from comictalker.comictalker import ComicTalker, TalkerError
from comictalker.talker_utils import cleanup_html
logger = logging.getLogger(__name__)
+class OutputEncoder(json.JSONEncoder):
+ def default(self, obj: Any) -> Any:
+ if isinstance(obj, pathlib.Path):
+ return str(obj)
+ if not isinstance(obj, str) and isinstance(obj, Collection):
+ return list(obj)
+
+ # Let the base class default method raise the TypeError
+ return json.JSONEncoder.default(self, obj)
+
+
class CLI:
def __init__(self, config: ct_ns, talkers: dict[str, ComicTalker]) -> None:
self.config = config
self.talkers = talkers
self.batch_mode = False
+ self.output_file = sys.stdout
+ if config.Runtime_Options__json:
+ self.output_file = sys.stderr
def current_talker(self) -> ComicTalker:
if self.config.Sources__source in self.talkers:
@@ -49,6 +69,56 @@ class CLI:
logger.error("Could not find the '%s' talker", self.config.Sources__source)
raise SystemExit(2)
+ def output(
+ self,
+ *args: Any,
+ file: TextIO | None = None,
+ force_output: bool = False,
+ already_logged: bool = False,
+ **kwargs: Any,
+ ) -> None:
+ if file is None:
+ file = self.output_file
+ if not args:
+ log_args: tuple[Any, ...] = ("",)
+ elif isinstance(args[0], str):
+ log_args = (args[0].strip("\n"), *args[1:])
+ else:
+ log_args = args
+ if not already_logged:
+ logger.info(*log_args, **kwargs)
+ if self.config.Runtime_Options__verbose > 0:
+ return
+ if not self.config.Runtime_Options__quiet or force_output:
+ print(*args, **kwargs, file=file)
+
+ def run(self) -> int:
+ if len(self.config.Runtime_Options__files) < 1:
+ logger.error("You must specify at least one filename. Use the -h option for more info")
+ return 1
+ return_code = 0
+
+ results: list[Result] = []
+ match_results = OnlineMatchResults()
+ self.batch_mode = len(self.config.Runtime_Options__files) > 1
+
+ for f in self.config.Runtime_Options__files:
+ results.append(self.process_file_cli(self.config.Commands__command, f, match_results))
+ if results[-1].status != Status.success:
+ return_code = 3
+ if self.config.Runtime_Options__json:
+ print(json.dumps(dataclasses.asdict(results[-1]), cls=OutputEncoder, indent=2))
+ sys.stdout.flush()
+ sys.stderr.flush()
+
+ self.post_process_matches(match_results)
+
+ if self.config.Runtime_Options__online:
+ self.output(
+ f"\nFiles tagged with metadata provided by {self.current_talker().name} {self.current_talker().website}",
+ )
+ return return_code
+
def actual_issue_data_fetch(self, issue_id: str) -> GenericMetadata:
# now get the particular issue data
try:
@@ -70,122 +140,108 @@ class CLI:
logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style])
return False
- print("Save complete.")
- logger.info("Save complete.")
+ self.output("Save complete.")
else:
if self.config.Runtime_Options__quiet:
- logger.info("dry-run option was set, so nothing was written")
- print("dry-run option was set, so nothing was written")
+ self.output("dry-run option was set, so nothing was written")
else:
- logger.info("dry-run option was set, so nothing was written, but here is the final set of tags:")
- print("dry-run option was set, so nothing was written, but here is the final set of tags:")
- print(f"{md}")
+ self.output("dry-run option was set, so nothing was written, but here is the final set of tags:")
+ self.output(f"{md}")
return True
- def display_match_set_for_choice(self, label: str, match_set: MultipleMatch) -> None:
- print(f"{match_set.ca.path} -- {label}:")
+ def display_match_set_for_choice(self, label: str, match_set: Result) -> None:
+ self.output(f"{match_set.original_path} -- {label}:", force_output=True)
# sort match list by year
- match_set.matches.sort(key=lambda k: k["year"] or 0)
+ match_set.online_results.sort(key=lambda k: k.year or 0)
- for counter, m in enumerate(match_set.matches, 1):
- print(
+ for counter, m in enumerate(match_set.online_results, 1):
+ self.output(
" {}. {} #{} [{}] ({}/{}) - {}".format(
counter,
- m["series"],
- m["issue_number"],
- m["publisher"],
- m["month"],
- m["year"],
- m["issue_title"],
- )
+ m.series,
+ m.issue_number,
+ m.publisher,
+ m.month,
+ m.year,
+ m.issue_title,
+ ),
+ force_output=True,
)
if self.config.Runtime_Options__interactive:
while True:
i = input("Choose a match #, or 's' to skip: ")
- if (i.isdigit() and int(i) in range(1, len(match_set.matches) + 1)) or i == "s":
+ if (i.isdigit() and int(i) in range(1, len(match_set.online_results) + 1)) or i == "s":
break
if i != "s":
# save the data!
# we know at this point, that the file is all good to go
- ca = match_set.ca
+ ca = ComicArchive(match_set.original_path)
md = self.create_local_metadata(ca)
- ct_md = self.actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"])
+ ct_md = self.actual_issue_data_fetch(match_set.online_results[int(i) - 1].issue_id)
if self.config.Issue_Identifier__clear_metadata_on_import:
md = ct_md
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
- f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
+ f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
if self.config.Issue_Identifier__auto_imprint:
md.fix_publisher()
+ match_set.md = md
+
self.actual_metadata_save(ca, md)
def post_process_matches(self, match_results: OnlineMatchResults) -> None:
+ def print_header(header: str) -> None:
+ self.output("", force_output=True)
+ self.output(header, force_output=True)
+ self.output("------------------", force_output=True)
+
# now go through the match results
if self.config.Runtime_Options__summary:
if len(match_results.good_matches) > 0:
- print("\nSuccessful matches:\n------------------")
+ print_header("Successful matches:")
for f in match_results.good_matches:
- print(f)
+ self.output(f, force_output=True)
if len(match_results.no_matches) > 0:
- print("\nNo matches:\n------------------")
+ print_header("No matches:")
for f in match_results.no_matches:
- print(f)
+ self.output(f, force_output=True)
if len(match_results.write_failures) > 0:
- print("\nFile Write Failures:\n------------------")
+ print_header("File Write Failures:")
for f in match_results.write_failures:
- print(f)
+ self.output(f, force_output=True)
if len(match_results.fetch_data_failures) > 0:
- print("\nNetwork Data Fetch Failures:\n------------------")
+ print_header("Network Data Fetch Failures:")
for f in match_results.fetch_data_failures:
- print(f)
+ self.output(f, force_output=True)
if not self.config.Runtime_Options__summary and not self.config.Runtime_Options__interactive:
# just quit if we're not interactive or showing the summary
return
if len(match_results.multiple_matches) > 0:
- print("\nArchives with multiple high-confidence matches:\n------------------")
+ self.output("\nArchives with multiple high-confidence matches:\n------------------", force_output=True)
for match_set in match_results.multiple_matches:
self.display_match_set_for_choice("Multiple high-confidence matches", match_set)
if len(match_results.low_confidence_matches) > 0:
- print("\nArchives with low-confidence matches:\n------------------")
+ self.output("\nArchives with low-confidence matches:\n------------------", force_output=True)
for match_set in match_results.low_confidence_matches:
- if len(match_set.matches) == 1:
+ if len(match_set.online_results) == 1:
label = "Single low-confidence match"
else:
label = "Multiple low-confidence matches"
self.display_match_set_for_choice(label, match_set)
- def run(self) -> None:
- if len(self.config.Runtime_Options__files) < 1:
- logger.error("You must specify at least one filename. Use the -h option for more info")
- return
-
- match_results = OnlineMatchResults()
- self.batch_mode = len(self.config.Runtime_Options__files) > 1
-
- for f in self.config.Runtime_Options__files:
- self.process_file_cli(f, match_results)
- sys.stdout.flush()
-
- self.post_process_matches(match_results)
-
- if self.config.Runtime_Options__online:
- print(
- f"\nFiles tagged with metadata provided by {self.current_talker().name} {self.current_talker().website}"
- )
-
def create_local_metadata(self, ca: ComicArchive) -> GenericMetadata:
md = GenericMetadata()
md.set_default_page_list(ca.get_number_of_pages())
@@ -216,7 +272,7 @@ class CLI:
return md
- def print(self, ca: ComicArchive) -> None:
+ def print(self, ca: ComicArchive) -> Result:
if not self.config.Runtime_Options__type:
page_count = ca.get_number_of_pages()
@@ -245,116 +301,152 @@ class CLI:
brief += "CoMet "
brief += "]"
- print(brief)
+ self.output(brief)
if self.config.Runtime_Options__quiet:
- return
+ return Result(Action.print, Status.success, ca.path)
- print()
+ self.output()
raw: str | bytes = ""
+ md = None
if not self.config.Runtime_Options__type or MetaDataStyle.CIX in self.config.Runtime_Options__type:
if ca.has_metadata(MetaDataStyle.CIX):
- print("--------- ComicRack tags ---------")
+ self.output("--------- ComicRack tags ---------")
try:
if self.config.Runtime_Options__raw:
raw = ca.read_raw_cix()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
- print(raw)
+ self.output(raw)
else:
- print(ca.read_cix())
+ md = ca.read_cix()
+ self.output(md)
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if not self.config.Runtime_Options__type or MetaDataStyle.CBI in self.config.Runtime_Options__type:
if ca.has_metadata(MetaDataStyle.CBI):
- print("------- ComicBookLover tags -------")
+ self.output("------- ComicBookLover tags -------")
try:
if self.config.Runtime_Options__raw:
raw = ca.read_raw_cbi()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
- print(raw)
+ self.output(raw)
else:
- print(ca.read_cbi())
+ md = ca.read_cbi()
+ self.output(md)
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
if not self.config.Runtime_Options__type or MetaDataStyle.COMET in self.config.Runtime_Options__type:
if ca.has_metadata(MetaDataStyle.COMET):
- print("----------- CoMet tags -----------")
+ self.output("----------- CoMet tags -----------")
try:
if self.config.Runtime_Options__raw:
raw = ca.read_raw_comet()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
- print(raw)
+ self.output(raw)
else:
- print(ca.read_comet())
+ md = ca.read_comet()
+ self.output(md)
except Exception as e:
logger.error("Failed to load metadata for %s: %s", ca.path, e)
- def delete(self, ca: ComicArchive) -> None:
- for metadata_style in self.config.Runtime_Options__type:
- style_name = MetaDataStyle.name[metadata_style]
- if ca.has_metadata(metadata_style):
- if not self.config.Runtime_Options__dryrun:
- if not ca.remove_metadata(metadata_style):
- print(f"{ca.path}: Tag removal seemed to fail!")
- else:
- print(f"{ca.path}: Removed {style_name} tags.")
+ return Result(Action.print, Status.success, ca.path, md=md)
+
+ def delete_style(self, ca: ComicArchive, style: int) -> Status:
+ style_name = MetaDataStyle.name[style]
+
+ if ca.has_metadata(style):
+ if not self.config.Runtime_Options__dryrun:
+ if ca.remove_metadata(style):
+ self.output(f"{ca.path}: Removed {style_name} tags.")
+ return Status.success
else:
- print(f"{ca.path}: dry-run. {style_name} tags not removed")
+ self.output(f"{ca.path}: Tag removal seemed to fail!")
+ return Status.write_failure
else:
- print(f"{ca.path}: This archive doesn't have {style_name} tags to remove.")
+ self.output(f"{ca.path}: dry-run. {style_name} tags not removed")
+ return Status.success
+ self.output(f"{ca.path}: This archive doesn't have {style_name} tags to remove.")
+ return Status.success
- def copy(self, ca: ComicArchive) -> None:
+ def delete(self, ca: ComicArchive) -> Result:
+ res = Result(Action.delete, Status.success, ca.path)
for metadata_style in self.config.Runtime_Options__type:
- dst_style_name = MetaDataStyle.name[metadata_style]
- if not self.config.Runtime_Options__overwrite and ca.has_metadata(metadata_style):
- print(f"{ca.path}: Already has {dst_style_name} tags. Not overwriting.")
- return
- if self.config.Commands__copy == metadata_style:
- print(f"{ca.path}: Destination and source are same: {dst_style_name}. Nothing to do.")
- return
-
- src_style_name = MetaDataStyle.name[self.config.Commands__copy]
- if ca.has_metadata(self.config.Commands__copy):
- if not self.config.Runtime_Options__dryrun:
- try:
- md = ca.read_metadata(self.config.Commands__copy)
- except Exception as e:
- md = GenericMetadata()
- logger.error("Failed to load metadata for %s: %s", ca.path, e)
-
- if self.config.Comic_Book_Lover__apply_transform_on_bulk_operation == MetaDataStyle.CBI:
- md = CBLTransformer(md, self.config).apply()
-
- if not ca.write_metadata(md, metadata_style):
- print(f"{ca.path}: Tag copy seemed to fail!")
- else:
- print(f"{ca.path}: Copied {src_style_name} tags to {dst_style_name}.")
- else:
- print(f"{ca.path}: dry-run. {src_style_name} tags not copied")
+ status = self.delete_style(ca, metadata_style)
+ if status == Status.success:
+ res.tags_deleted.append(metadata_style)
else:
- print(f"{ca.path}: This archive doesn't have {src_style_name} tags to copy.")
+ res.status = status
+ return res
- def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> None:
+ def copy_style(self, ca: ComicArchive, md: GenericMetadata, style: int) -> Status:
+ dst_style_name = MetaDataStyle.name[style]
+ if not self.config.Runtime_Options__overwrite and ca.has_metadata(style):
+ self.output(f"{ca.path}: Already has {dst_style_name} tags. Not overwriting.")
+ return Status.existing_tags
+ if self.config.Commands__copy == style:
+ self.output(f"{ca.path}: Destination and source are same: {dst_style_name}. Nothing to do.")
+ return Status.existing_tags
+
+ src_style_name = MetaDataStyle.name[self.config.Commands__copy]
+ if ca.has_metadata(self.config.Commands__copy):
+ if not self.config.Runtime_Options__dryrun:
+ if self.config.Comic_Book_Lover__apply_transform_on_bulk_operation == MetaDataStyle.CBI:
+ md = CBLTransformer(md, self.config).apply()
+
+ if ca.write_metadata(md, style):
+ self.output(f"{ca.path}: Copied {src_style_name} tags to {dst_style_name}.")
+ return Status.success
+ else:
+ self.output(f"{ca.path}: Tag copy seemed to fail!")
+ return Status.write_failure
+ else:
+ self.output(f"{ca.path}: dry-run. {src_style_name} tags not copied")
+ return Status.success
+ self.output(f"{ca.path}: This archive doesn't have {src_style_name} tags to copy.")
+ return Status.read_failure
+
+ def copy(self, ca: ComicArchive) -> Result:
+ res = Result(Action.copy, Status.success, ca.path)
+ try:
+ res.md = ca.read_metadata(self.config.Commands__copy)
+ except Exception as e:
+ logger.error("Failed to load metadata for %s: %s", ca.path, e)
+ return res
+ for metadata_style in self.config.Runtime_Options__type:
+ status = self.copy_style(ca, res.md, metadata_style)
+ if status == Status.success:
+ res.tags_written.append(metadata_style)
+ else:
+ res.status = status
+ return res
+
+ def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> Result:
if not self.config.Runtime_Options__overwrite:
for metadata_style in self.config.Runtime_Options__type:
if ca.has_metadata(metadata_style):
- print(f"{ca.path}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.")
- return
+ self.output(f"{ca.path}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.")
+ return Result(
+ Action.save,
+ original_path=ca.path,
+ status=Status.existing_tags,
+ tags_written=self.config.Runtime_Options__type,
+ )
if self.batch_mode:
- print(f"Processing {ca.path}...")
+ self.output(f"Processing {utils.path_to_short_str(ca.path)}...")
md = self.create_local_metadata(ca)
if md.issue is None or md.issue == "":
if self.config.Auto_Tag__assume_1_if_no_issue_num:
md.issue = "1"
+ matches: list[IssueResult] = []
# now, search online
if self.config.Runtime_Options__online:
if self.config.Runtime_Options__issue_id is not None:
@@ -363,32 +455,52 @@ class CLI:
ct_md = self.current_talker().fetch_comic_data(self.config.Runtime_Options__issue_id)
except TalkerError as e:
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
- match_results.fetch_data_failures.append(str(ca.path.absolute()))
- return
+ res = Result(
+ Action.save,
+ original_path=ca.path,
+ status=Status.fetch_data_failure,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.fetch_data_failures.append(res)
+ return res
if ct_md is None:
logger.error("No match for ID %s was found.", self.config.Runtime_Options__issue_id)
- match_results.no_matches.append(str(ca.path.absolute()))
- return
+ res = Result(
+ Action.save,
+ status=Status.match_failure,
+ original_path=ca.path,
+ match_status=MatchStatus.no_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.no_matches.append(res)
+ return res
if self.config.Comic_Book_Lover__apply_transform_on_import:
ct_md = CBLTransformer(ct_md, self.config).apply()
else:
if md is None or md.is_empty:
logger.error("No metadata given to search online with!")
- match_results.no_matches.append(str(ca.path.absolute()))
- return
+ res = Result(
+ Action.save,
+ status=Status.match_failure,
+ original_path=ca.path,
+ match_status=MatchStatus.no_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.no_matches.append(res)
+ return res
ii = IssueIdentifier(ca, self.config, self.current_talker())
def myoutput(text: str) -> None:
if self.config.Runtime_Options__verbose:
- IssueIdentifier.default_write_output(text)
+ self.output(text)
# use our overlaid MD struct to search
ii.set_additional_metadata(md)
ii.only_use_additional_meta_data = True
- ii.set_output_function(myoutput)
+ ii.set_output_function(functools.partial(self.output, already_logged=True))
ii.cover_page_index = md.get_cover_page_index_list()[0]
matches = ii.search()
@@ -416,35 +528,75 @@ class CLI:
if choices:
if low_confidence:
logger.error("Online search: Multiple low confidence matches. Save aborted")
- match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
- return
+ res = Result(
+ Action.save,
+ status=Status.match_failure,
+ original_path=ca.path,
+ online_results=matches,
+ match_status=MatchStatus.low_confidence_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.low_confidence_matches.append(res)
+ return res
logger.error("Online search: Multiple good matches. Save aborted")
- match_results.multiple_matches.append(MultipleMatch(ca, matches))
- return
+ res = Result(
+ Action.save,
+ status=Status.match_failure,
+ original_path=ca.path,
+ online_results=matches,
+ match_status=MatchStatus.multiple_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.multiple_matches.append(res)
+ return res
if low_confidence and self.config.Runtime_Options__abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
- match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
- return
+ res = Result(
+ Action.save,
+ status=Status.match_failure,
+ original_path=ca.path,
+ online_results=matches,
+ match_status=MatchStatus.low_confidence_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.low_confidence_matches.append(res)
+ return res
if not found_match:
logger.error("Online search: No match found. Save aborted")
- match_results.no_matches.append(str(ca.path.absolute()))
- return
+ res = Result(
+ Action.save,
+ status=Status.match_failure,
+ original_path=ca.path,
+ online_results=matches,
+ match_status=MatchStatus.no_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.no_matches.append(res)
+ return res
# we got here, so we have a single match
# now get the particular issue data
- ct_md = self.actual_issue_data_fetch(matches[0]["issue_id"])
+ ct_md = self.actual_issue_data_fetch(matches[0].issue_id)
if ct_md.is_empty:
- match_results.fetch_data_failures.append(str(ca.path.absolute()))
- return
+ res = Result(
+ Action.save,
+ status=Status.fetch_data_failure,
+ original_path=ca.path,
+ online_results=matches,
+ match_status=MatchStatus.good_match,
+ tags_written=self.config.Runtime_Options__type,
+ )
+ match_results.fetch_data_failures.append(res)
+ return res
if self.config.Issue_Identifier__clear_metadata_on_import:
md = GenericMetadata()
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
- f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
+ + f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(
ct_md.replace(
@@ -456,13 +608,24 @@ class CLI:
if self.config.Issue_Identifier__auto_imprint:
md.fix_publisher()
+ res = Result(
+ Action.save,
+ status=Status.success,
+ original_path=ca.path,
+ online_results=matches,
+ match_status=MatchStatus.good_match,
+ md=md,
+ tags_written=self.config.Runtime_Options__type,
+ )
# ok, done building our metadata. time to save
- if not self.actual_metadata_save(ca, md):
- match_results.write_failures.append(str(ca.path.absolute()))
+ if self.actual_metadata_save(ca, md):
+ match_results.good_matches.append(res)
else:
- match_results.good_matches.append(str(ca.path.absolute()))
+ res.status = Status.write_failure
+ match_results.write_failures.append(res)
+ return res
- def rename(self, ca: ComicArchive) -> None:
+ def rename(self, ca: ComicArchive) -> Result:
original_path = ca.path
msg_hdr = ""
if self.batch_mode:
@@ -472,7 +635,7 @@ class CLI:
if md.series is None:
logger.error(msg_hdr + "Can't rename without series name")
- return
+ return Result(Action.rename, Status.read_failure, original_path)
new_ext = "" # default
if self.config.File_Rename__set_extension_based_on_archive:
@@ -492,26 +655,27 @@ class CLI:
new_name = renamer.determine_name(ext=new_ext)
except ValueError:
logger.exception(
- msg_hdr + "Invalid format string!\n"
- "Your rename template is invalid!\n\n"
- "%s\n\n"
- "Please consult the template help in the settings "
- "and the documentation on the format at "
- "https://docs.python.org/3/library/string.html#format-string-syntax",
+ msg_hdr
+ + "Invalid format string!\n"
+ + "Your rename template is invalid!\n\n"
+ + "%s\n\n"
+ + "Please consult the template help in the settings "
+ + "and the documentation on the format at "
+ + "https://docs.python.org/3/library/string.html#format-string-syntax",
self.config.File_Rename__template,
)
- return
+ return Result(Action.rename, Status.rename_failure, original_path, md=md)
except Exception:
logger.exception("Formatter failure: %s metadata: %s", self.config.File_Rename__template, renamer.metadata)
- return
+ return Result(Action.rename, Status.rename_failure, original_path, md=md)
folder = get_rename_dir(ca, self.config.File_Rename__dir if self.config.File_Rename__move_to_dir else None)
full_path = folder / new_name
if full_path == ca.path:
- print(msg_hdr + "Filename is already good!", file=sys.stderr)
- return
+ self.output(msg_hdr + "Filename is already good!")
+ return Result(Action.rename, Status.success, original_path, full_path, md=md)
suffix = ""
if not self.config.Runtime_Options__dryrun:
@@ -520,41 +684,41 @@ class CLI:
ca.rename(utils.unique_file(full_path))
except OSError:
logger.exception("Failed to rename comic archive: %s", ca.path)
+ return Result(Action.rename, Status.write_failure, original_path, full_path, md=md)
else:
suffix = " (dry-run, no change)"
- print(f"renamed '{original_path.name}' -> '{new_name}' {suffix}")
+ self.output(f"renamed '{original_path.name}' -> '{new_name}' {suffix}")
+ return Result(Action.rename, Status.success, original_path, md=md)
- def export(self, ca: ComicArchive) -> None:
+ def export(self, ca: ComicArchive) -> Result:
msg_hdr = ""
if self.batch_mode:
msg_hdr = f"{ca.path}: "
if ca.is_zip():
logger.error(msg_hdr + "Archive is already a zip file.")
- return
+ return Result(Action.export, Status.success, ca.path)
filename_path = ca.path
new_file = filename_path.with_suffix(".cbz")
if self.config.Runtime_Options__abort_on_conflict and new_file.exists():
- print(msg_hdr + f"{new_file.name} already exists in the that folder.")
- return
+ self.output(msg_hdr + f"{new_file.name} already exists in the that folder.")
+ return Result(Action.export, Status.write_failure, ca.path)
new_file = utils.unique_file(new_file)
delete_success = False
export_success = False
if not self.config.Runtime_Options__dryrun:
- if ca.export_as_zip(new_file):
- export_success = True
+ if export_success := ca.export_as_zip(new_file):
if self.config.Runtime_Options__delete_after_zip_export:
try:
filename_path.unlink(missing_ok=True)
delete_success = True
except OSError:
logger.exception(msg_hdr + "Error deleting original archive after export")
- delete_success = False
else:
# last export failed, so remove the zip, if it exists
new_file.unlink(missing_ok=True)
@@ -562,8 +726,8 @@ class CLI:
msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}"
if self.config.Runtime_Options__delete_after_zip_export:
msg += " and delete original."
- print(msg)
- return
+ self.output(msg)
+ return Result(Action.export, Status.success, ca.path, new_file)
msg = msg_hdr
if export_success:
@@ -573,42 +737,40 @@ class CLI:
else:
msg += "Archive failed to export!"
- print(msg)
+ self.output(msg)
- def process_file_cli(self, filename: str, match_results: OnlineMatchResults) -> None:
+ return Result(Action.export, Status.success, ca.path, new_file)
+
+ def process_file_cli(self, command: Action, filename: str, match_results: OnlineMatchResults) -> Result:
if not os.path.lexists(filename):
logger.error("Cannot find %s", filename)
- return
+ return Result(command, Status.read_failure, pathlib.Path(filename))
ca = ComicArchive(filename, str(graphics_path / "nocover.png"))
if not ca.seems_to_be_a_comic_archive():
logger.error("Sorry, but %s is not a comic archive!", filename)
- return
+ return Result(Action.rename, Status.read_failure, ca.path)
- if not ca.is_writable() and (
- self.config.Commands__delete
- or self.config.Commands__copy
- or self.config.Commands__save
- or self.config.Commands__rename
- ):
+ if not ca.is_writable() and (command in (Action.delete, Action.copy, Action.save, Action.rename)):
logger.error("This archive is not writable")
- return
+ return Result(command, Status.write_permission_failure, ca.path)
- if self.config.Commands__print:
- self.print(ca)
+ if command == Action.print:
+ return self.print(ca)
- elif self.config.Commands__delete:
- self.delete(ca)
+ elif command == Action.delete:
+ return self.delete(ca)
- elif self.config.Commands__copy is not None:
- self.copy(ca)
+ elif command == Action.copy is not None:
+ return self.copy(ca)
- elif self.config.Commands__save:
- self.save(ca, match_results)
+ elif command == Action.save:
+ return self.save(ca, match_results)
- elif self.config.Commands__rename:
- self.rename(ca)
+ elif command == Action.rename:
+ return self.rename(ca)
- elif self.config.Commands__export_to_zip:
- self.export(ca)
+ elif command == Action.export:
+ return self.export(ca)
+ return Result(None, Status.read_failure, ca.path) # type: ignore[arg-type]
diff --git a/comictaggerlib/ctsettings/commandline.py b/comictaggerlib/ctsettings/commandline.py
index 24cfad9..0b44007 100644
--- a/comictaggerlib/ctsettings/commandline.py
+++ b/comictaggerlib/ctsettings/commandline.py
@@ -32,6 +32,7 @@ from comictaggerlib.ctsettings.types import (
metadata_type_single,
parse_metadata_from_string,
)
+from comictaggerlib.resulttypes import Action
logger = logging.getLogger(__name__)
@@ -112,7 +113,7 @@ def register_runtime(parser: settngs.Manager) -> None:
"-i",
"--interactive",
action="store_true",
- help="""Interactively query the user when there are\nmultiple matches for an online search.\n\n""",
+ help="""Interactively query the user when there are\nmultiple matches for an online search. Disabled json output\n\n""",
file=False,
)
parser.add_setting(
@@ -159,6 +160,9 @@ def register_runtime(parser: settngs.Manager) -> None:
parser.add_setting("--darkmode", action="store_true", help="Windows only. Force a dark pallet", file=False)
parser.add_setting("-g", "--glob", action="store_true", help="Windows only. Enable globbing", file=False)
parser.add_setting("--quiet", "-q", action="store_true", help="Don't say much (for print mode).", file=False)
+ parser.add_setting(
+ "--json", "-j", action="store_true", help="Output json on stdout. Ignored in interactive mode.", file=False
+ )
parser.add_setting(
"-t",
@@ -187,14 +191,18 @@ def register_commands(parser: settngs.Manager) -> None:
parser.add_setting(
"-p",
"--print",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.print,
help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""",
file=False,
)
parser.add_setting(
"-d",
"--delete",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.delete,
help="Deletes the tag block of specified type (via -t).\n",
file=False,
)
@@ -209,33 +217,43 @@ def register_commands(parser: settngs.Manager) -> None:
parser.add_setting(
"-s",
"--save",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.save,
help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n",
file=False,
)
parser.add_setting(
"-r",
"--rename",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.print,
help="Rename the file based on specified tag style.",
file=False,
)
parser.add_setting(
"-e",
"--export-to-zip",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.export,
help="Export RAR archive to Zip format.",
file=False,
)
parser.add_setting(
"--only-save-config",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.save_config,
help="Only save the configuration (eg, Comic Vine API key) and quit.",
file=False,
)
parser.add_setting(
"--list-plugins",
- action="store_true",
+ dest="command",
+ action="store_const",
+ const=Action.list_plugins,
help="List the available plugins.\n\n",
file=False,
)
@@ -251,21 +269,11 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
parser.exit(
status=1,
message=f"ComicTagger {ctversion.version}: Copyright (c) 2012-2022 ComicTagger Team\n"
- "Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
+ + "Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
config[0].Runtime_Options__no_gui = any(
- [
- config[0].Commands__print,
- config[0].Commands__delete,
- config[0].Commands__save,
- config[0].Commands__copy,
- config[0].Commands__rename,
- config[0].Commands__export_to_zip,
- config[0].Commands__only_save_config,
- config[0].Commands__list_plugins,
- config[0].Runtime_Options__no_gui,
- ]
+ (config[0].Commands__command, config[0].Runtime_Options__no_gui, config[0].Commands__copy)
)
if platform.system() == "Windows" and config[0].Runtime_Options__glob:
@@ -277,20 +285,24 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
for item in globs:
config[0].Runtime_Options__files.extend(glob.glob(item))
+ if config[0].Runtime_Options__json and config[0].Runtime_Options__interactive:
+ config[0].Runtime_Options__json = False
+
if (
- not config[0].Commands__only_save_config
+ config[0].Commands__command != Action.save_config
and config[0].Runtime_Options__no_gui
and not config[0].Runtime_Options__files
):
parser.exit(message="Command requires at least one filename!\n", status=1)
- if config[0].Commands__delete and not config[0].Runtime_Options__type:
+ if config[0].Commands__command == Action.delete and not config[0].Runtime_Options__type:
parser.exit(message="Please specify the type to delete with -t\n", status=1)
- if config[0].Commands__save and not config[0].Runtime_Options__type:
+ if config[0].Commands__command == Action.save and not config[0].Runtime_Options__type:
parser.exit(message="Please specify the type to save with -t\n", status=1)
if config[0].Commands__copy:
+ config[0].Commands__command = Action.copy
if not config[0].Runtime_Options__type:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
diff --git a/comictaggerlib/ctsettings/file.py b/comictaggerlib/ctsettings/file.py
index c712423..880459e 100644
--- a/comictaggerlib/ctsettings/file.py
+++ b/comictaggerlib/ctsettings/file.py
@@ -50,7 +50,7 @@ def identifier(parser: settngs.Manager) -> None:
parser.add_setting("--series-match-search-thresh", default=90, type=int)
parser.add_setting(
"--clear-metadata",
- default=True,
+ default=False,
help="Clears all existing metadata during import, default is to merges metadata.\nMay be used in conjunction with -o, -f and -m.\n\n",
dest="clear_metadata_on_import",
action=argparse.BooleanOptionalAction,
@@ -78,12 +78,6 @@ def identifier(parser: settngs.Manager) -> None:
action=argparse.BooleanOptionalAction,
help="Enables the publisher filter",
)
- parser.add_setting(
- "--clear-form-before-populating",
- default=False,
- action=argparse.BooleanOptionalAction,
- help="Clears all existing metadata when applying metadata from comic source",
- )
def dialog(parser: settngs.Manager) -> None:
diff --git a/comictaggerlib/ctsettings/plugin.py b/comictaggerlib/ctsettings/plugin.py
index dc6d820..f4f604e 100644
--- a/comictaggerlib/ctsettings/plugin.py
+++ b/comictaggerlib/ctsettings/plugin.py
@@ -36,8 +36,8 @@ def archiver(manager: settngs.Manager) -> None:
)
-def register_talker_settings(manager: settngs.Manager) -> None:
- for talker in comictaggerlib.ctsettings.talkers.values():
+def register_talker_settings(manager: settngs.Manager, talkers: dict[str, ComicTalker]) -> None:
+ for talker in talkers.values():
def api_options(manager: settngs.Manager) -> None:
# The default needs to be unset or None.
@@ -76,10 +76,10 @@ def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[c
return config
-def validate_talker_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
+def validate_talker_settings(config: settngs.Config[ct_ns], talkers: dict[str, ComicTalker]) -> settngs.Config[ct_ns]:
# Apply talker settings from config file
cfg = settngs.normalize_config(config, True, True)
- for talker in list(comictaggerlib.ctsettings.talkers.values()):
+ for talker in list(talkers.values()):
try:
cfg[0][group_for_plugin(talker)] = talker.parse_settings(cfg[0][group_for_plugin(talker)])
except Exception as e:
@@ -90,12 +90,12 @@ def validate_talker_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct
return cast(settngs.Config[ct_ns], settngs.get_namespace(cfg, file=True, cmdline=True))
-def validate_plugin_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
+def validate_plugin_settings(config: settngs.Config[ct_ns], talkers: dict[str, ComicTalker]) -> settngs.Config[ct_ns]:
config = validate_archive_settings(config)
- config = validate_talker_settings(config)
+ config = validate_talker_settings(config, talkers)
return config
-def register_plugin_settings(manager: settngs.Manager) -> None:
+def register_plugin_settings(manager: settngs.Manager, talkers: dict[str, ComicTalker]) -> None:
manager.add_persistent_group("Archive", archiver, False)
- register_talker_settings(manager)
+ register_talker_settings(manager, talkers)
diff --git a/comictaggerlib/ctsettings/settngs_namespace.py b/comictaggerlib/ctsettings/settngs_namespace.py
index f8cfab8..66d03f3 100644
--- a/comictaggerlib/ctsettings/settngs_namespace.py
+++ b/comictaggerlib/ctsettings/settngs_namespace.py
@@ -5,18 +5,13 @@ import settngs
import comicapi.genericmetadata
import comictaggerlib.ctsettings.types
import comictaggerlib.defaults
+import comictaggerlib.resulttypes
class settngs_namespace(settngs.TypedNS):
Commands__version: bool
- Commands__print: bool
- Commands__delete: bool
+ Commands__command: comictaggerlib.resulttypes.Action
Commands__copy: int
- Commands__save: bool
- Commands__rename: bool
- Commands__export_to_zip: bool
- Commands__only_save_config: bool
- Commands__list_plugins: bool
Runtime_Options__config: comictaggerlib.ctsettings.types.ComicTaggerPaths
Runtime_Options__verbose: int
@@ -36,6 +31,7 @@ class settngs_namespace(settngs.TypedNS):
Runtime_Options__darkmode: bool
Runtime_Options__glob: bool
Runtime_Options__quiet: bool
+ Runtime_Options__json: bool
Runtime_Options__type: list[int]
Runtime_Options__overwrite: bool
Runtime_Options__no_gui: bool
@@ -63,7 +59,6 @@ class settngs_namespace(settngs.TypedNS):
Issue_Identifier__sort_series_by_year: bool
Issue_Identifier__exact_series_matches_first: bool
Issue_Identifier__always_use_publisher_filter: bool
- Issue_Identifier__clear_form_before_populating: bool
Filename_Parsing__complicated_parser: bool
Filename_Parsing__remove_c2c: bool
diff --git a/comictaggerlib/issueidentifier.py b/comictaggerlib/issueidentifier.py
index a2fe036..82c6d58 100644
--- a/comictaggerlib/issueidentifier.py
+++ b/comictaggerlib/issueidentifier.py
@@ -17,7 +17,7 @@ from __future__ import annotations
import io
import logging
-import sys
+from operator import attrgetter
from typing import Any, Callable
from typing_extensions import NotRequired, TypedDict
@@ -102,8 +102,8 @@ class IssueIdentifier:
self.publisher_filter = [s.strip().casefold() for s in config.Issue_Identifier__publisher_filter]
self.additional_metadata = GenericMetadata()
- self.output_function: Callable[[str], None] = IssueIdentifier.default_write_output
- self.callback: Callable[[int, int], None] | None = None
+ self.output_function: Callable[[str], None] = print
+ self.progress_callback: Callable[[int, int], None] | None = None
self.cover_url_callback: Callable[[bytes], None] | None = None
self.search_result = self.result_no_matches
self.cover_page_index = 0
@@ -208,7 +208,7 @@ class IssueIdentifier:
return None
def set_progress_callback(self, cb_func: Callable[[int, int], None]) -> None:
- self.callback = cb_func
+ self.progress_callback = cb_func
def set_cover_url_callback(self, cb_func: Callable[[bytes], None]) -> None:
self.cover_url_callback = cb_func
@@ -264,16 +264,33 @@ class IssueIdentifier:
return search_keys
- @staticmethod
- def default_write_output(text: str) -> None:
- sys.stdout.write(text)
- sys.stdout.flush()
-
- def log_msg(self, msg: Any, newline: bool = True) -> None:
+ def log_msg(self, msg: Any) -> None:
msg = str(msg)
- if newline:
- msg += "\n"
- self.output_function(msg)
+ for handler in logging.getLogger().handlers:
+ handler.flush()
+ self.output(msg)
+
+ def output(self, *args: Any, file: Any = None, **kwargs: Any) -> None:
+ # We intercept and discard the file argument otherwise everything is passed to self.output_function
+
+ # Ensure args[0] is defined and is a string for logger.info
+ if not args:
+ log_args: tuple[Any, ...] = ("",)
+ elif isinstance(args[0], str):
+ log_args = (args[0].strip("\n"), *args[1:])
+ else:
+ log_args = args
+ log_msg = " ".join([str(x) for x in log_args])
+
+ # Always send to logger so that we have a record for troubleshooting
+ logger.info(log_msg, **kwargs)
+
+ # If we are verbose or quiet we don't need to call the output function
+ if self.config.Runtime_Options__verbose > 0 or self.config.Runtime_Options__quiet:
+ return
+
+ # default output is stdout
+ self.output_function(*args, **kwargs)
def get_issue_cover_match_score(
self,
@@ -281,7 +298,6 @@ class IssueIdentifier:
alt_urls: list[str],
local_cover_hash_list: list[int],
use_remote_alternates: bool = False,
- use_log: bool = True,
) -> Score:
# local_cover_hash_list is a list of pre-calculated hashes.
# use_remote_alternates - indicates to use alternate covers from CV
@@ -332,10 +348,7 @@ class IssueIdentifier:
if self.cancel:
raise IssueIdentifierCancelled
- if use_log and use_remote_alternates:
- self.log_msg(f"[{len(remote_cover_list) - 1} alt. covers]", False)
- if use_log:
- self.log_msg("[ ", False)
+ self.log_msg(f"[{len(remote_cover_list) - 1} alt. covers]")
score_list = []
done = False
@@ -343,8 +356,8 @@ class IssueIdentifier:
for remote_cover_item in remote_cover_list:
score = ImageHasher.hamming_distance(local_cover_hash, remote_cover_item["hash"])
score_list.append(Score(score=score, url=remote_cover_item["url"], hash=remote_cover_item["hash"]))
- if use_log:
- self.log_msg(score, False)
+
+ self.log_msg(f" - {score:03}")
if score <= self.strong_score_thresh:
# such a good score, we can quit now, since for sure we have a winner
@@ -353,9 +366,6 @@ class IssueIdentifier:
if done:
break
- if use_log:
- self.log_msg(" ]", False)
-
best_score_item = min(score_list, key=lambda x: x["score"])
return best_score_item
@@ -446,8 +456,8 @@ class IssueIdentifier:
self.log_msg("Searching in " + str(len(series_second_round_list)) + " series")
- if self.callback is not None:
- self.callback(0, len(series_second_round_list))
+ if self.progress_callback is not None:
+ self.progress_callback(0, len(series_second_round_list))
# now sort the list by name length
series_second_round_list.sort(key=lambda x: len(x.name), reverse=False)
@@ -485,13 +495,12 @@ class IssueIdentifier:
# Do first round of cover matching
counter = len(shortlist)
for series, issue in shortlist:
- if self.callback is not None:
- self.callback(counter, len(shortlist) * 3)
+ if self.progress_callback is not None:
+ self.progress_callback(counter, len(shortlist) * 3)
counter += 1
self.log_msg(
- f"Examining covers for ID: {series.id} {series.name} ({series.start_year}) ...",
- newline=False,
+ f"Examining covers for ID: {series.id} {series.name} ({series.start_year}):",
)
# Now check the cover match against the primary image
@@ -505,8 +514,8 @@ class IssueIdentifier:
logger.info("Adding cropped cover to the hashlist")
try:
- image_url = issue.cover_image or ""
- alt_urls = issue.alternate_images
+ image_url = issue._cover_image or ""
+ alt_urls = issue._alternate_images
score_item = self.get_issue_cover_match_score(
image_url, alt_urls, hash_list, use_remote_alternates=False
@@ -516,28 +525,28 @@ class IssueIdentifier:
self.match_list = []
return self.match_list
- match: IssueResult = {
- "series": f"{series.name} ({series.start_year})",
- "distance": score_item["score"],
- "issue_number": keys["issue_number"],
- "cv_issue_count": series.count_of_issues,
- "url_image_hash": score_item["hash"],
- "issue_title": issue.title or "",
- "issue_id": issue.issue_id or "",
- "series_id": series.id,
- "month": issue.month,
- "year": issue.year,
- "publisher": None,
- "image_url": image_url,
- "alt_image_urls": alt_urls,
- "description": issue.description or "",
- }
+ match = IssueResult(
+ series=f"{series.name} ({series.start_year})",
+ distance=score_item["score"],
+ issue_number=keys["issue_number"],
+ cv_issue_count=series.count_of_issues,
+ url_image_hash=score_item["hash"],
+ issue_title=issue.title or "",
+ issue_id=issue.issue_id or "",
+ series_id=series.id,
+ month=issue.month,
+ year=issue.year,
+ publisher=None,
+ image_url=image_url,
+ alt_image_urls=alt_urls,
+ description=issue.description or "",
+ )
if series.publisher is not None:
- match["publisher"] = series.publisher
+ match.publisher = series.publisher
self.match_list.append(match)
- self.log_msg(f" --> {match['distance']}", newline=False)
+ self.log_msg(f"best score {match.distance:03}")
self.log_msg("")
@@ -547,28 +556,27 @@ class IssueIdentifier:
return self.match_list
# sort list by image match scores
- self.match_list.sort(key=lambda k: k["distance"])
+ self.match_list.sort(key=attrgetter("distance"))
lst = []
for i in self.match_list:
- lst.append(i["distance"])
+ lst.append(i.distance)
- self.log_msg(f"Compared to covers in {len(self.match_list)} issue(s):", newline=False)
- self.log_msg(str(lst))
+ self.log_msg(f"Compared to covers in {len(self.match_list)} issue(s): {lst}")
def print_match(item: IssueResult) -> None:
self.log_msg(
"-----> {} #{} {} ({}/{}) -- score: {}".format(
- item["series"],
- item["issue_number"],
- item["issue_title"],
- item["month"],
- item["year"],
- item["distance"],
+ item.series,
+ item.issue_number,
+ item.issue_title,
+ item.month,
+ item.year,
+ item.distance,
)
)
- best_score: int = self.match_list[0]["distance"]
+ best_score: int = self.match_list[0].distance
if best_score >= self.min_score_thresh:
# we have 1 or more low-confidence matches (all bad cover scores)
@@ -585,14 +593,14 @@ class IssueIdentifier:
second_match_list = []
counter = 2 * len(self.match_list)
for m in self.match_list:
- if self.callback is not None:
- self.callback(counter, len(self.match_list) * 3)
+ if self.progress_callback is not None:
+ self.progress_callback(counter, len(self.match_list) * 3)
counter += 1
- self.log_msg(f"Examining alternate covers for ID: {m['series_id']} {m['series']} ...", newline=False)
+ self.log_msg(f"Examining alternate covers for ID: {m.series_id} {m.series}:")
try:
score_item = self.get_issue_cover_match_score(
- m["image_url"],
- m["alt_image_urls"],
+ m.image_url,
+ m.alt_image_urls,
hash_list,
use_remote_alternates=True,
)
@@ -605,7 +613,7 @@ class IssueIdentifier:
if score_item["score"] < self.min_alternate_score_thresh:
second_match_list.append(m)
- m["distance"] = score_item["score"]
+ m.distance = score_item["score"]
if len(second_match_list) == 0:
if len(self.match_list) == 1:
@@ -626,17 +634,17 @@ class IssueIdentifier:
self.match_list = second_match_list
# sort new list by image match scores
- self.match_list.sort(key=lambda k: k["distance"])
- best_score = self.match_list[0]["distance"]
+ self.match_list.sort(key=attrgetter("distance"))
+ best_score = self.match_list[0].distance
self.log_msg("[Second round cover matching: best score = {best_score}]")
# now drop down into the rest of the processing
- if self.callback is not None:
- self.callback(99, 100)
+ if self.progress_callback is not None:
+ self.progress_callback(99, 100)
# now pare down list, remove any item more than specified distant from the top scores
for match_item in reversed(self.match_list):
- if match_item["distance"] > best_score + self.min_score_distance:
+ if match_item.distance > best_score + self.min_score_distance:
self.match_list.remove(match_item)
# One more test for the case choosing limited series first issue vs a trade with the same cover:
@@ -644,11 +652,11 @@ class IssueIdentifier:
if len(self.match_list) >= 2 and keys["issue_count"] is not None and keys["issue_count"] != 1:
new_list = []
for match in self.match_list:
- if match["cv_issue_count"] != 1:
+ if match.cv_issue_count != 1:
new_list.append(match)
else:
self.log_msg(
- f"Removing series {match['series']} [{match['series_id']}] from consideration (only 1 issue)"
+ f"Removing series {match.series} [{match.series_id}] from consideration (only 1 issue)"
)
if len(new_list) > 0:
diff --git a/comictaggerlib/issueselectionwindow.py b/comictaggerlib/issueselectionwindow.py
index 3abee0a..4968e18 100644
--- a/comictaggerlib/issueselectionwindow.py
+++ b/comictaggerlib/issueselectionwindow.py
@@ -222,7 +222,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
# list selection was changed, update the issue cover
issue = self.issue_list[self.issue_id]
- if not (issue.issue and issue.year and issue.month and issue.cover_image and issue.title):
+ if not (issue.issue and issue.year and issue.month and issue._cover_image and issue.title):
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
try:
issue = self.talker.fetch_comic_data(issue_id=self.issue_id)
@@ -231,7 +231,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
QtWidgets.QApplication.restoreOverrideCursor()
self.issue_number = issue.issue or ""
- self.coverWidget.set_issue_details(self.issue_id, [issue.cover_image or "", *issue.alternate_images])
+ self.coverWidget.set_issue_details(self.issue_id, [issue._cover_image or "", *issue._alternate_images])
if issue.description is None:
self.set_description(self.teDescription, "")
else:
diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py
index 47c412d..c42a8e4 100644
--- a/comictaggerlib/main.py
+++ b/comictaggerlib/main.py
@@ -35,6 +35,8 @@ from comictaggerlib import cli, ctsettings
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ctversion import version
from comictaggerlib.log import setup_logging
+from comictaggerlib.resulttypes import Action
+from comictalker.comictalker import ComicTalker
if sys.version_info < (3, 10):
import importlib_metadata
@@ -106,6 +108,7 @@ class App:
self.config: settngs.Config[ct_ns]
self.initial_arg_parser = ctsettings.initial_commandline_parser()
self.config_load_success = False
+ self.talkers: dict[str, ComicTalker]
def run(self) -> None:
configure_locale()
@@ -119,35 +122,76 @@ class App:
def load_plugins(self, opts: argparse.Namespace) -> None:
comicapi.comicarchive.load_archive_plugins()
- ctsettings.talkers = comictalker.get_talkers(version, opts.config.user_cache_dir)
+ self.talkers = comictalker.get_talkers(version, opts.config.user_cache_dir)
def list_plugins(
self, talkers: list[comictalker.ComicTalker], archivers: list[type[comicapi.comicarchive.Archiver]]
) -> None:
- print("Metadata Sources: (ID: Name URL)") # noqa: T201
- for talker in talkers:
- print(f"{talker.id}: {talker.name} {talker.default_api_url}") # noqa: T201
+ if self.config[0].Runtime_Options__json:
+ for talker in talkers:
+ print( # noqa: T201
+ json.dumps(
+ {
+ "type": "talker",
+ "id": talker.id,
+ "name": talker.name,
+ "website": talker.website,
+ }
+ )
+ )
- print("\nComic Archive: (Name: extension, exe)") # noqa: T201
- for archiver in archivers:
- a = archiver()
- print(f"{a.name()}: {a.extension()}, {a.exe}") # noqa: T201
+ for archiver in archivers:
+ try:
+ a = archiver()
+ print( # noqa: T201
+ json.dumps(
+ {
+ "type": "archiver",
+ "enabled": a.enabled,
+ "name": a.name(),
+ "extension": a.extension(),
+ "exe": a.exe,
+ }
+ )
+ )
+ except Exception:
+ print( # noqa: T201
+ json.dumps(
+ {
+ "type": "archiver",
+ "enabled": archiver.enabled,
+ "name": "",
+ "extension": "",
+ "exe": archiver.exe,
+ }
+ )
+ )
+ else:
+ print("Metadata Sources: (ID: Name URL)") # noqa: T201
+ for talker in talkers:
+ print(f"{talker.id}: {talker.name} {talker.website}") # noqa: T201
+
+ print("\nComic Archive: (Name: extension, exe)") # noqa: T201
+ for archiver in archivers:
+ a = archiver()
+ print(f"{a.name()}: {a.extension()}, {a.exe}") # noqa: T201
def initialize(self) -> argparse.Namespace:
- conf, _ = self.initial_arg_parser.parse_known_args()
+ conf, _ = self.initial_arg_parser.parse_known_intermixed_args()
+
assert conf is not None
setup_logging(conf.verbose, conf.config.user_log_dir)
return conf
def register_settings(self) -> None:
self.manager = settngs.Manager(
- "A utility for reading and writing metadata to comic archives.\n\n\n"
- + "If no options are given, %(prog)s will run in windowed mode.",
- "For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
+ description="A utility for reading and writing metadata to comic archives.\n\n\n"
+ + "If no options are given, %(prog)s will run in windowed mode.\nPlease keep the '-v' option separated '-so -v' not '-sov'",
+ epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
)
ctsettings.register_commandline_settings(self.manager)
ctsettings.register_file_settings(self.manager)
- ctsettings.register_plugin_settings(self.manager)
+ ctsettings.register_plugin_settings(self.manager, getattr(self, "talkers", {}))
def parse_settings(self, config_paths: ctsettings.ComicTaggerPaths, *args: str) -> settngs.Config[ct_ns]:
cfg, self.config_load_success = ctsettings.parse_config(
@@ -158,7 +202,7 @@ class App:
config = ctsettings.validate_commandline_settings(config, self.manager)
config = ctsettings.validate_file_settings(config)
- config = ctsettings.validate_plugin_settings(config)
+ config = ctsettings.validate_plugin_settings(config, getattr(self, "talkers", {}))
return config
def initialize_dirs(self, paths: ctsettings.ComicTaggerPaths) -> None:
@@ -178,10 +222,7 @@ class App:
# config already loaded
error = None
- talkers = ctsettings.talkers
- del ctsettings.talkers
-
- if len(talkers) < 1:
+ if len(self.talkers) < 1:
error = error = (
"Failed to load any talkers, please re-install and check the log located in '"
+ str(self.config[0].Runtime_Options__config.user_log_dir)
@@ -198,11 +239,11 @@ class App:
comicapi.utils.load_publishers()
update_publishers(self.config)
- if self.config[0].Commands__list_plugins:
- self.list_plugins(list(talkers.values()), comicapi.comicarchive.archivers)
+ if self.config[0].Commands__command == Action.list_plugins:
+ self.list_plugins(list(self.talkers.values()), comicapi.comicarchive.archivers)
return
- if self.config[0].Commands__only_save_config:
+ if self.config[0].Commands__command == Action.save_config:
if self.config_load_success:
settings_path = self.config[0].Runtime_Options__config.user_config_dir / "settings.json"
if self.config_load_success:
@@ -224,7 +265,7 @@ class App:
if not gui.qt_available:
raise gui.import_error
- return gui.open_tagger_window(talkers, self.config, error)
+ return gui.open_tagger_window(self.talkers, self.config, error)
except ImportError:
self.config[0].Runtime_Options__no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
@@ -233,8 +274,9 @@ class App:
if error and error[1]:
print(f"A fatal error occurred please check the log for more information: {error[0]}") # noqa: T201
raise SystemExit(1)
+
try:
- cli.CLI(self.config[0], talkers).run()
+ raise SystemExit(cli.CLI(self.config[0], self.talkers).run())
except Exception:
logger.exception("CLI mode failed")
diff --git a/comictaggerlib/matchselectionwindow.py b/comictaggerlib/matchselectionwindow.py
index 7106372..9dccca6 100644
--- a/comictaggerlib/matchselectionwindow.py
+++ b/comictaggerlib/matchselectionwindow.py
@@ -93,15 +93,15 @@ class MatchSelectionWindow(QtWidgets.QDialog):
for row, match in enumerate(self.matches):
self.twList.insertRow(row)
- item_text = match["series"]
+ item_text = match.series
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
- if match["publisher"] is not None:
- item_text = str(match["publisher"])
+ if match.publisher is not None:
+ item_text = str(match.publisher)
else:
item_text = "Unknown"
item = QtWidgets.QTableWidgetItem(item_text)
@@ -111,10 +111,10 @@ class MatchSelectionWindow(QtWidgets.QDialog):
month_str = ""
year_str = "????"
- if match["month"] is not None:
- month_str = f"-{int(match['month']):02d}"
- if match["year"] is not None:
- year_str = str(match["year"])
+ if match.month is not None:
+ month_str = f"-{int(match.month):02d}"
+ if match.year is not None:
+ year_str = str(match.year)
item_text = year_str + month_str
item = QtWidgets.QTableWidgetItem(item_text)
@@ -122,7 +122,7 @@ class MatchSelectionWindow(QtWidgets.QDialog):
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
- item_text = match["issue_title"]
+ item_text = match.issue_title
if item_text is None:
item_text = ""
item = QtWidgets.QTableWidgetItem(item_text)
@@ -146,14 +146,15 @@ class MatchSelectionWindow(QtWidgets.QDialog):
if prev is not None and prev.row() == curr.row():
return
+ match = self.current_match()
self.altCoverWidget.set_issue_details(
- self.current_match()["issue_id"],
- [self.current_match()["image_url"], *self.current_match()["alt_image_urls"]],
+ match.issue_id,
+ [match.image_url, *match.alt_image_urls],
)
- if self.current_match()["description"] is None:
+ if match.description is None:
self.teDescription.setText("")
else:
- self.teDescription.setText(self.current_match()["description"])
+ self.teDescription.setText(match.description)
def set_cover_image(self) -> None:
self.archiveCoverWidget.set_archive(self.comic_archive)
diff --git a/comictaggerlib/resulttypes.py b/comictaggerlib/resulttypes.py
index 5bfe9a6..e0196ba 100644
--- a/comictaggerlib/resulttypes.py
+++ b/comictaggerlib/resulttypes.py
@@ -1,11 +1,55 @@
from __future__ import annotations
-from typing_extensions import TypedDict
+import dataclasses
+import pathlib
+import sys
+from enum import Enum, auto
+from typing import Any
-from comicapi.comicarchive import ComicArchive
+from comicapi import utils
+from comicapi.genericmetadata import GenericMetadata
+
+if sys.version_info < (3, 11):
+
+ class StrEnum(str, Enum):
+ """
+ Enum where members are also (and must be) strings
+ """
+
+ def __new__(cls, *values: Any) -> Any:
+ "values must already be of type `str`"
+ if len(values) > 3:
+ raise TypeError(f"too many arguments for str(): {values!r}")
+ if len(values) == 1:
+ # it must be a string
+ if not isinstance(values[0], str):
+ raise TypeError(f"{values[0]!r} is not a string")
+ if len(values) >= 2:
+ # check that encoding argument is a string
+ if not isinstance(values[1], str):
+ raise TypeError(f"encoding must be a string, not {values[1]!r}")
+ if len(values) == 3:
+ # check that errors argument is a string
+ if not isinstance(values[2], str):
+ raise TypeError("errors must be a string, not %r" % (values[2]))
+ value = str(*values)
+ member = str.__new__(cls, value)
+ member._value_ = value
+ return member
+
+ @staticmethod
+ def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str:
+ """
+ Return the lower-cased version of the member name.
+ """
+ return name.lower()
+
+else:
+ from enum import StrEnum
-class IssueResult(TypedDict):
+@dataclasses.dataclass
+class IssueResult:
series: str
distance: int
issue_number: str
@@ -21,18 +65,71 @@ class IssueResult(TypedDict):
alt_image_urls: list[str]
description: str
+ def __str__(self) -> str:
+ return f"series: {self.series}; series id: {self.series_id}; issue number: {self.issue_number}; issue id: {self.issue_id}; published: {self.month} {self.year}"
+
+class Action(StrEnum):
+ print = auto()
+ delete = auto()
+ copy = auto()
+ save = auto()
+ rename = auto()
+ export = auto()
+ save_config = auto()
+ list_plugins = auto()
+
+
+class MatchStatus(StrEnum):
+ good_match = auto()
+ no_match = auto()
+ multiple_match = auto()
+ low_confidence_match = auto()
+
+
+class Status(StrEnum):
+ success = auto()
+ match_failure = auto()
+ write_failure = auto()
+ fetch_data_failure = auto()
+ existing_tags = auto()
+ read_failure = auto()
+ write_permission_failure = auto()
+ rename_failure = auto()
+
+
+@dataclasses.dataclass
class OnlineMatchResults:
- def __init__(self) -> None:
- self.good_matches: list[str] = []
- self.no_matches: list[str] = []
- self.multiple_matches: list[MultipleMatch] = []
- self.low_confidence_matches: list[MultipleMatch] = []
- self.write_failures: list[str] = []
- self.fetch_data_failures: list[str] = []
+ good_matches: list[Result] = dataclasses.field(default_factory=list)
+ no_matches: list[Result] = dataclasses.field(default_factory=list)
+ multiple_matches: list[Result] = dataclasses.field(default_factory=list)
+ low_confidence_matches: list[Result] = dataclasses.field(default_factory=list)
+ write_failures: list[Result] = dataclasses.field(default_factory=list)
+ fetch_data_failures: list[Result] = dataclasses.field(default_factory=list)
-class MultipleMatch:
- def __init__(self, ca: ComicArchive, match_list: list[IssueResult]) -> None:
- self.ca: ComicArchive = ca
- self.matches: list[IssueResult] = match_list
+@dataclasses.dataclass
+class Result:
+ action: Action
+ status: Status | None
+
+ original_path: pathlib.Path
+ renamed_path: pathlib.Path | None = None
+
+ online_results: list[IssueResult] = dataclasses.field(default_factory=list)
+ match_status: MatchStatus | None = None
+
+ md: GenericMetadata | None = None
+
+ tags_deleted: list[int] = dataclasses.field(default_factory=list)
+ tags_written: list[int] = dataclasses.field(default_factory=list)
+
+ def __str__(self) -> str:
+ if len(self.online_results) == 0:
+ matches = None
+ elif len(self.online_results) == 1:
+ matches = str(self.online_results[0])
+ else:
+ matches = "\n" + "".join([f" - {x}" for x in self.online_results])
+ path_str = utils.path_to_short_str(self.original_path, self.renamed_path)
+ return f"{path_str}: {matches}"
diff --git a/comictaggerlib/seriesselectionwindow.py b/comictaggerlib/seriesselectionwindow.py
index 3ce82f0..15abf8f 100644
--- a/comictaggerlib/seriesselectionwindow.py
+++ b/comictaggerlib/seriesselectionwindow.py
@@ -265,9 +265,11 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
def log_id_output(self, text: str) -> None:
if self.iddialog is not None:
- print(text, end=" ") # noqa: T201
+ self.iddialog.textEdit.append(text.rstrip())
self.iddialog.textEdit.ensureCursorVisible()
- self.iddialog.textEdit.insertPlainText(text)
+ QtCore.QCoreApplication.processEvents()
+ QtCore.QCoreApplication.processEvents()
+ QtCore.QCoreApplication.processEvents()
def identify_progress(self, cur: int, total: int) -> None:
if self.iddialog is not None:
@@ -325,8 +327,8 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
if found_match is not None:
self.iddialog.accept()
- self.series_id = utils.xlate(found_match["series_id"]) or ""
- self.issue_number = found_match["issue_number"]
+ self.series_id = utils.xlate(found_match.series_id) or ""
+ self.issue_number = found_match.issue_number
self.select_by_id()
self.show_issues()
diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py
index ef29b7c..6bb5219 100644
--- a/comictaggerlib/settingswindow.py
+++ b/comictaggerlib/settingswindow.py
@@ -387,7 +387,6 @@ class SettingsWindow(QtWidgets.QDialog):
self.switch_parser()
- self.cbxClearFormBeforePopulating.setChecked(self.config[0].Issue_Identifier__clear_form_before_populating)
self.cbxUseFilter.setChecked(self.config[0].Issue_Identifier__always_use_publisher_filter)
self.cbxSortByYear.setChecked(self.config[0].Issue_Identifier__sort_series_by_year)
self.cbxExactMatches.setChecked(self.config[0].Issue_Identifier__exact_series_matches_first)
@@ -505,7 +504,6 @@ class SettingsWindow(QtWidgets.QDialog):
self.cbxProtofoliusIssueNumberScheme.isChecked()
)
- self.config[0].Issue_Identifier__clear_form_before_populating = self.cbxClearFormBeforePopulating.isChecked()
self.config[0].Issue_Identifier__always_use_publisher_filter = self.cbxUseFilter.isChecked()
self.config[0].Issue_Identifier__sort_series_by_year = self.cbxSortByYear.isChecked()
self.config[0].Issue_Identifier__exact_series_matches_first = self.cbxExactMatches.isChecked()
@@ -542,9 +540,7 @@ class SettingsWindow(QtWidgets.QDialog):
QtWidgets.QDialog.accept(self)
def update_talkers_config(self) -> None:
- ctsettings.talkers = self.talkers
- self.config = ctsettings.plugin.validate_talker_settings(self.config)
- del ctsettings.talkers
+ self.config = ctsettings.plugin.validate_talker_settings(self.config, self.talkers)
def select_rar(self) -> None:
self.select_file(self.leRarExePath, "RAR")
diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py
index 30a1030..8a5601b 100644
--- a/comictaggerlib/taggerwindow.py
+++ b/comictaggerlib/taggerwindow.py
@@ -57,7 +57,7 @@ from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.pagebrowser import PageBrowserWindow
from comictaggerlib.pagelisteditor import PageListEditor
from comictaggerlib.renamewindow import RenameWindow
-from comictaggerlib.resulttypes import IssueResult, MultipleMatch, OnlineMatchResults
+from comictaggerlib.resulttypes import Action, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
from comictaggerlib.seriesselectionwindow import SeriesSelectionWindow
from comictaggerlib.settingswindow import SettingsWindow
from comictaggerlib.ui import ui_path
@@ -292,6 +292,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
current_logs = ""
root_logger = logging.getLogger()
qapplogwindow = ApplicationLogWindow(
+ self.config[0].Runtime_Options__config.user_log_dir,
QTextEditLogger(logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s"), logging.DEBUG),
parent=self,
)
@@ -1083,12 +1084,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
if self.config[0].Comic_Book_Lover__apply_transform_on_import:
new_metadata = CBLTransformer(new_metadata, self.config[0]).apply()
- if self.config[0].Issue_Identifier__clear_form_before_populating:
+ if self.config[0].Issue_Identifier__clear_metadata_on_import:
self.clear_form()
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
- f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {new_metadata.issue_id}]"
+ f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {new_metadata.issue_id}]"
)
self.metadata.overlay(
new_metadata.replace(
@@ -1684,7 +1685,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
try:
- ct_md = self.current_talker().fetch_comic_data(match["issue_id"])
+ ct_md = self.current_talker().fetch_comic_data(match.issue_id)
except TalkerError:
logger.exception("Save aborted.")
@@ -1697,7 +1698,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
return ct_md
def auto_tag_log(self, text: str) -> None:
- IssueIdentifier.default_write_output(text)
if self.atprogdialog is not None:
self.atprogdialog.textEdit.append(text.rstrip())
self.atprogdialog.textEdit.ensureCursorVisible()
@@ -1778,16 +1778,48 @@ class TaggerWindow(QtWidgets.QMainWindow):
if choices:
if low_confidence:
self.auto_tag_log("Online search: Multiple low-confidence matches. Save aborted\n")
- match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
+ match_results.low_confidence_matches.append(
+ Result(
+ Action.save,
+ Status.match_failure,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.low_confidence_match,
+ )
+ )
else:
self.auto_tag_log("Online search: Multiple matches. Save aborted\n")
- match_results.multiple_matches.append(MultipleMatch(ca, matches))
+ match_results.multiple_matches.append(
+ Result(
+ Action.save,
+ Status.match_failure,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.multiple_match,
+ )
+ )
elif low_confidence and not dlg.auto_save_on_low:
self.auto_tag_log("Online search: Low confidence match. Save aborted\n")
- match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
+ match_results.low_confidence_matches.append(
+ Result(
+ Action.save,
+ Status.match_failure,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.low_confidence_match,
+ )
+ )
elif not found_match:
self.auto_tag_log("Online search: No match found. Save aborted\n")
- match_results.no_matches.append(str(ca.path.absolute()))
+ match_results.no_matches.append(
+ Result(
+ Action.save,
+ Status.match_failure,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.no_match,
+ )
+ )
else:
# a single match!
if low_confidence:
@@ -1796,7 +1828,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
# now get the particular issue data
ct_md = self.actual_issue_data_fetch(matches[0])
if ct_md is None:
- match_results.fetch_data_failures.append(str(ca.path.absolute()))
+ match_results.fetch_data_failures.append(
+ Result(
+ Action.save,
+ Status.fetch_data_failure,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.good_match,
+ )
+ )
if ct_md is not None:
if dlg.cbxRemoveMetadata.isChecked():
@@ -1804,7 +1844,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
else:
notes = (
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
- f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
+ f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
)
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
@@ -1812,10 +1852,26 @@ class TaggerWindow(QtWidgets.QMainWindow):
md.fix_publisher()
if not ca.write_metadata(md, self.save_data_style):
- match_results.write_failures.append(str(ca.path.absolute()))
+ match_results.write_failures.append(
+ Result(
+ Action.save,
+ Status.write_failure,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.good_match,
+ )
+ )
self.auto_tag_log("Save failed ;-(\n")
else:
- match_results.good_matches.append(str(ca.path.absolute()))
+ match_results.good_matches.append(
+ Result(
+ Action.save,
+ Status.success,
+ ca.path,
+ online_results=matches,
+ match_status=MatchStatus.good_match,
+ )
+ )
success = True
self.auto_tag_log("Save complete!\n")
ca.load_cache([MetaDataStyle.CBI, MetaDataStyle.CIX])
diff --git a/comictaggerlib/ui/applicationlogwindow.ui b/comictaggerlib/ui/applicationlogwindow.ui
index 77d8741..bc8b19e 100644
--- a/comictaggerlib/ui/applicationlogwindow.ui
+++ b/comictaggerlib/ui/applicationlogwindow.ui
@@ -14,6 +14,16 @@
Log Window
+ -
+
+
+ Log Location:
+
+
+ true
+
+
+
-
diff --git a/comictalker/talkers/comicvine.py b/comictalker/talkers/comicvine.py
index f79075a..b6b9121 100644
--- a/comictalker/talkers/comicvine.py
+++ b/comictalker/talkers/comicvine.py
@@ -642,12 +642,12 @@ class ComicVineTalker(ComicTalker):
series_aliases=series.aliases,
)
if issue.get("image") is None:
- md.cover_image = ""
+ md._cover_image = ""
else:
- md.cover_image = issue.get("image", {}).get("super_url", "")
+ md._cover_image = issue.get("image", {}).get("super_url", "")
for alt in issue.get("associated_images", []):
- md.alternate_images.append(alt["original_url"])
+ md._alternate_images.append(alt["original_url"])
for character in issue.get("character_credits", set()):
md.characters.add(character["name"])
diff --git a/setup.cfg b/setup.cfg
index 14ac754..e117cc5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -290,7 +290,7 @@ deps =
[flake8]
max-line-length = 120
-extend-ignore = E203, E501, A003
+extend-ignore = E203, E501, A003, T202
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
per-file-ignores =
comictaggerlib/cli.py: T20
diff --git a/testing/comicvine.py b/testing/comicvine.py
index 1b95034..1edad87 100644
--- a/testing/comicvine.py
+++ b/testing/comicvine.py
@@ -181,7 +181,7 @@ comic_issue_result = comicapi.genericmetadata.GenericMetadata(
issue_id=str(cv_issue_result["results"]["id"]),
series=cv_issue_result["results"]["volume"]["name"],
series_id=str(cv_issue_result["results"]["volume"]["id"]),
- cover_image=cv_issue_result["results"]["image"]["super_url"],
+ _cover_image=cv_issue_result["results"]["image"]["super_url"],
issue=cv_issue_result["results"]["issue_number"],
volume=None,
title=cv_issue_result["results"]["name"],
@@ -236,7 +236,7 @@ cv_md = comicapi.genericmetadata.GenericMetadata(
rights=None,
identifier=None,
last_mark=None,
- cover_image=cv_issue_result["results"]["image"]["super_url"],
+ _cover_image=cv_issue_result["results"]["image"]["super_url"],
)
diff --git a/tests/conftest.py b/tests/conftest.py
index c20e5ba..d2a4a44 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,11 @@
from __future__ import annotations
import copy
+import datetime
import io
import shutil
import unittest.mock
+from argparse import Namespace
from collections.abc import Generator
from typing import Any
@@ -15,6 +17,7 @@ from pyrate_limiter import Limiter, RequestRate
import comicapi.comicarchive
import comicapi.genericmetadata
+import comictaggerlib.cli
import comictaggerlib.ctsettings
import comictalker
import comictalker.comiccacher
@@ -127,10 +130,22 @@ def comicvine_api(monkeypatch, cbz, comic_cache, mock_version, config) -> comict
return cv
+@pytest.fixture
+def mock_now(monkeypatch):
+ class mydatetime:
+ time = datetime.datetime(2022, 4, 16, 15, 52, 26)
+
+ @classmethod
+ def now(cls):
+ return cls.time
+
+ monkeypatch.setattr(comictaggerlib.cli, "datetime", mydatetime)
+
+
@pytest.fixture
def mock_version(monkeypatch):
- version = "1.4.4a9.dev20"
- version_tuple = (1, 4, 4, "dev20")
+ version = "1.3.2a5"
+ version_tuple = (1, 3, 2)
monkeypatch.setattr(comictaggerlib.ctversion, "version", version)
monkeypatch.setattr(comictaggerlib.ctversion, "__version__", version)
@@ -182,6 +197,24 @@ def config(tmp_path):
yield defaults
+@pytest.fixture
+def plugin_config(tmp_path):
+ from comictaggerlib.main import App
+
+ ns = Namespace(config=comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config"))
+ app = App()
+ app.load_plugins(ns)
+ app.register_settings()
+
+ defaults = app.parse_settings(ns.config, "")
+ defaults[0].Runtime_Options__config.user_data_dir.mkdir(parents=True, exist_ok=True)
+ defaults[0].Runtime_Options__config.user_config_dir.mkdir(parents=True, exist_ok=True)
+ defaults[0].Runtime_Options__config.user_cache_dir.mkdir(parents=True, exist_ok=True)
+ defaults[0].Runtime_Options__config.user_state_dir.mkdir(parents=True, exist_ok=True)
+ defaults[0].Runtime_Options__config.user_log_dir.mkdir(parents=True, exist_ok=True)
+ yield (defaults, app.talkers)
+
+
@pytest.fixture
def comic_cache(config, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]:
yield comictalker.comiccacher.ComicCacher(config[0].Runtime_Options__config.user_cache_dir, mock_version[0])
diff --git a/tests/integration_test.py b/tests/integration_test.py
new file mode 100644
index 0000000..cc3850b
--- /dev/null
+++ b/tests/integration_test.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import settngs
+
+import comicapi.comicarchive
+import comicapi.comicinfoxml
+import comicapi.genericmetadata
+import comictaggerlib.resulttypes
+from comictaggerlib import ctsettings
+from comictaggerlib.cli import CLI
+from comictalker.comictalker import ComicTalker
+
+
+def test_save(
+ plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
+ tmp_comic,
+ comicvine_api,
+ md_saved,
+ mock_now,
+) -> None:
+ # Overwrite the series so it has definitely changed
+ tmp_comic.write_cix(md_saved.replace(series="nothing"))
+
+ md = tmp_comic.read_cix()
+
+ # Check that it changed
+ assert md != md_saved
+
+ # Clear the cached metadata
+ tmp_comic.reset_cache()
+
+ # Setup the app
+ config = plugin_config[0]
+ talkers = plugin_config[1]
+
+ # Save
+ config[0].Commands__command = comictaggerlib.resulttypes.Action.save
+
+ # Check online, should be intercepted by comicvine_api
+ config[0].Runtime_Options__online = True
+ # Use the temporary comic we created
+ config[0].Runtime_Options__files = [tmp_comic.path]
+ # Save ComicRack tags
+ config[0].Runtime_Options__type = [comicapi.comicarchive.MetaDataStyle.CIX]
+ # Search using the correct series since we just put the wrong series name in the CBZ
+ config[0].Runtime_Options__metadata = comicapi.genericmetadata.GenericMetadata(series=md_saved.series)
+ # Run ComicTagger
+ CLI(config[0], talkers).run()
+
+ # Read the CBZ
+ md = tmp_comic.read_cix()
+
+ # Validate that we got the correct metadata back
+ assert md == md_saved
+
+
+def test_delete(
+ plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
+ tmp_comic,
+ comicvine_api,
+ md_saved,
+ mock_now,
+) -> None:
+ md = tmp_comic.read_cix()
+
+ # Check that the metadata starts correct
+ assert md == md_saved
+
+ # Clear the cached metadata
+ tmp_comic.reset_cache()
+
+ # Setup the app
+ config = plugin_config[0]
+ talkers = plugin_config[1]
+
+ # Delete
+ config[0].Commands__command = comictaggerlib.resulttypes.Action.delete
+
+ # Use the temporary comic we created
+ config[0].Runtime_Options__files = [tmp_comic.path]
+ # Delete ComicRack tags
+ config[0].Runtime_Options__type = [comicapi.comicarchive.MetaDataStyle.CIX]
+ # Run ComicTagger
+ CLI(config[0], talkers).run()
+
+ # Read the CBZ
+ md = tmp_comic.read_cix()
+
+ # Currently we set the default page list on load
+ empty_md = comicapi.genericmetadata.GenericMetadata()
+ empty_md.set_default_page_list(tmp_comic.get_number_of_pages())
+
+ # Validate that we got an empty metadata back
+ assert md == empty_md
diff --git a/tests/issueidentifier_test.py b/tests/issueidentifier_test.py
index 2b4605c..4702ddb 100644
--- a/tests/issueidentifier_test.py
+++ b/tests/issueidentifier_test.py
@@ -8,6 +8,7 @@ from PIL import Image
import comictaggerlib.issueidentifier
import testing.comicdata
import testing.comicvine
+from comictaggerlib.resulttypes import IssueResult
def test_crop(cbz_double_cover, config, tmp_path, comicvine_api):
@@ -51,23 +52,23 @@ def test_search(cbz, config, comicvine_api):
config, definitions = config
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
results = ii.search()
- cv_expected = {
- "series": f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})",
- "distance": 0,
- "issue_number": testing.comicvine.cv_issue_result["results"]["issue_number"],
- "alt_image_urls": [],
- "cv_issue_count": testing.comicvine.cv_volume_result["results"]["count_of_issues"],
- "issue_title": testing.comicvine.cv_issue_result["results"]["name"],
- "issue_id": str(testing.comicvine.cv_issue_result["results"]["id"]),
- "series_id": str(testing.comicvine.cv_volume_result["results"]["id"]),
- "month": testing.comicvine.date[1],
- "year": testing.comicvine.date[2],
- "publisher": testing.comicvine.cv_volume_result["results"]["publisher"]["name"],
- "image_url": testing.comicvine.cv_issue_result["results"]["image"]["super_url"],
- "description": testing.comicvine.cv_issue_result["results"]["description"],
- }
+ cv_expected = IssueResult(
+ series=f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})",
+ distance=0,
+ issue_number=testing.comicvine.cv_issue_result["results"]["issue_number"],
+ alt_image_urls=[],
+ cv_issue_count=testing.comicvine.cv_volume_result["results"]["count_of_issues"],
+ issue_title=testing.comicvine.cv_issue_result["results"]["name"],
+ issue_id=str(testing.comicvine.cv_issue_result["results"]["id"]),
+ series_id=str(testing.comicvine.cv_volume_result["results"]["id"]),
+ month=testing.comicvine.date[1],
+ year=testing.comicvine.date[2],
+ publisher=testing.comicvine.cv_volume_result["results"]["publisher"]["name"],
+ image_url=testing.comicvine.cv_issue_result["results"]["image"]["super_url"],
+ description=testing.comicvine.cv_issue_result["results"]["description"],
+ url_image_hash=1747255366011518976,
+ )
for r, e in zip(results, [cv_expected]):
- del r["url_image_hash"]
assert r == e