From 19112ac79b8fd99fca645e6bba24a2ad19a53469 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Tue, 6 Dec 2022 00:20:01 -0800 Subject: [PATCH 01/22] Update Settings --- comicapi/utils.py | 4 +- comictagger.py | 5 +- comictaggerlib/autotagmatchwindow.py | 23 +- comictaggerlib/autotagprogresswindow.py | 4 +- comictaggerlib/autotagstartwindow.py | 36 +- comictaggerlib/cbltransformer.py | 20 +- comictaggerlib/cli.py | 210 ++++++------ comictaggerlib/coverimagewidget.py | 20 +- comictaggerlib/exportwindow.py | 5 +- comictaggerlib/fileselectionlist.py | 10 +- comictaggerlib/gui.py | 143 ++++++++ comictaggerlib/imagefetcher.py | 9 +- comictaggerlib/imagehasher.py | 0 comictaggerlib/issueidentifier.py | 30 +- comictaggerlib/issueselectionwindow.py | 13 +- comictaggerlib/log.py | 42 +++ comictaggerlib/main.py | 336 +++++++------------ comictaggerlib/matchselectionwindow.py | 11 +- comictaggerlib/options.py | 418 ----------------------- comictaggerlib/pagebrowser.py | 2 +- comictaggerlib/pagelisteditor.py | 2 +- comictaggerlib/renamewindow.py | 39 ++- comictaggerlib/settings.py | 420 ------------------------ comictaggerlib/settings/__init__.py | 17 + comictaggerlib/settings/cmdline.py | 343 +++++++++++++++++++ comictaggerlib/settings/file.py | 229 +++++++++++++ comictaggerlib/settings/manager.py | 260 +++++++++++++++ comictaggerlib/settings/types.py | 122 +++++++ comictaggerlib/settingswindow.py | 170 +++++----- comictaggerlib/taggerwindow.py | 196 +++++------ comictaggerlib/volumeselectionwindow.py | 34 +- requirements.txt | 1 + setup.py | 2 +- tests/comiccacher_test.py | 4 +- tests/conftest.py | 18 +- 35 files changed, 1736 insertions(+), 1462 deletions(-) create mode 100644 comictaggerlib/gui.py mode change 100755 => 100644 comictaggerlib/imagehasher.py create mode 100644 comictaggerlib/log.py mode change 100755 => 100644 comictaggerlib/main.py delete mode 100644 comictaggerlib/options.py delete mode 100644 comictaggerlib/settings.py create mode 100644 comictaggerlib/settings/__init__.py create mode 100644 comictaggerlib/settings/cmdline.py create mode 100644 comictaggerlib/settings/file.py create mode 100644 comictaggerlib/settings/manager.py create mode 100644 comictaggerlib/settings/types.py diff --git a/comicapi/utils.py b/comicapi/utils.py index 42cf19f..497e336 100644 --- a/comicapi/utils.py +++ b/comicapi/utils.py @@ -234,7 +234,7 @@ def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None: publishers[publisher] = ImprintDict(publisher, new_publishers[publisher]) -class ImprintDict(dict): +class ImprintDict(dict): # type: ignore """ ImprintDict takes a publisher and a dict or mapping of lowercased imprint names to the proper imprint name. Retrieving a value from an @@ -242,7 +242,7 @@ class ImprintDict(dict): if the key does not exist the key is returned as the publisher unchanged """ - def __init__(self, publisher: str, mapping: tuple | Mapping = (), **kwargs: dict) -> None: + def __init__(self, publisher: str, mapping: tuple | Mapping = (), **kwargs: dict) -> None: # type: ignore super().__init__(mapping, **kwargs) self.publisher = publisher diff --git a/comictagger.py b/comictagger.py index 947f5a4..17c515d 100755 --- a/comictagger.py +++ b/comictagger.py @@ -2,8 +2,9 @@ from __future__ import annotations import localefix -from comictaggerlib.main import ctmain +from comictaggerlib.main import App if __name__ == "__main__": localefix.configure_locale() - ctmain() + + App().run() diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py index 516dc04..4baf51d 100644 --- a/comictaggerlib/autotagmatchwindow.py +++ b/comictaggerlib/autotagmatchwindow.py @@ -23,9 +23,9 @@ from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi.comicarchive import MetaDataStyle from comicapi.genericmetadata import GenericMetadata +from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.resulttypes import IssueResult, MultipleMatch -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import reduce_widget_font_size from comictalker.talkerbase import ComicTalker @@ -42,23 +42,28 @@ class AutoTagMatchWindow(QtWidgets.QDialog): match_set_list: list[MultipleMatch], style: int, fetch_func: Callable[[IssueResult], GenericMetadata], - settings: ComicTaggerSettings, + options: settings.OptionValues, talker_api: ComicTalker, ) -> None: super().__init__(parent) uic.loadUi(ui_path / "matchselectionwindow.ui", self) - self.settings = settings + self.options = options self.current_match_set: MultipleMatch = match_set_list[0] - self.altCoverWidget = CoverImageWidget(self.altCoverContainer, talker_api, CoverImageWidget.AltCoverMode) + self.altCoverWidget = CoverImageWidget( + self.altCoverContainer, + CoverImageWidget.AltCoverMode, + options["runtime"]["config"].user_cache_dir, + talker_api, + ) gridlayout = QtWidgets.QGridLayout(self.altCoverContainer) gridlayout.addWidget(self.altCoverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) - self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, talker_api, CoverImageWidget.ArchiveMode) + self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, CoverImageWidget.ArchiveMode, None, None) gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer) gridlayout.addWidget(self.archiveCoverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) @@ -240,10 +245,10 @@ class AutoTagMatchWindow(QtWidgets.QDialog): md = ca.read_metadata(self._style) if md.is_empty: md = ca.metadata_from_filename( - self.settings.complicated_parser, - self.settings.remove_c2c, - self.settings.remove_fcbd, - self.settings.remove_publisher, + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], ) # now get the particular issue data diff --git a/comictaggerlib/autotagprogresswindow.py b/comictaggerlib/autotagprogresswindow.py index 477381b..b8362d1 100644 --- a/comictaggerlib/autotagprogresswindow.py +++ b/comictaggerlib/autotagprogresswindow.py @@ -34,13 +34,13 @@ class AutoTagProgressWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "autotagprogresswindow.ui", self) self.archiveCoverWidget = CoverImageWidget( - self.archiveCoverContainer, talker_api, CoverImageWidget.DataMode, False + self.archiveCoverContainer, CoverImageWidget.DataMode, None, None, False ) gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer) gridlayout.addWidget(self.archiveCoverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) - self.testCoverWidget = CoverImageWidget(self.testCoverContainer, talker_api, CoverImageWidget.DataMode, False) + self.testCoverWidget = CoverImageWidget(self.testCoverContainer, CoverImageWidget.DataMode, None, None, False) gridlayout = QtWidgets.QGridLayout(self.testCoverContainer) gridlayout.addWidget(self.testCoverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) diff --git a/comictaggerlib/autotagstartwindow.py b/comictaggerlib/autotagstartwindow.py index bae9f19..4ee1c5f 100644 --- a/comictaggerlib/autotagstartwindow.py +++ b/comictaggerlib/autotagstartwindow.py @@ -19,14 +19,14 @@ import logging from PyQt5 import QtCore, QtWidgets, uic -from comictaggerlib.settings import ComicTaggerSettings +from comictaggerlib import settings from comictaggerlib.ui import ui_path logger = logging.getLogger(__name__) class AutoTagStartWindow(QtWidgets.QDialog): - def __init__(self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings, msg: str) -> None: + def __init__(self, parent: QtWidgets.QWidget, options: settings.OptionValues, msg: str) -> None: super().__init__(parent) uic.loadUi(ui_path / "autotagstartwindow.ui", self) @@ -36,19 +36,20 @@ class AutoTagStartWindow(QtWidgets.QDialog): QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint) ) - self.settings = settings + self.options = options self.cbxSpecifySearchString.setChecked(False) self.cbxSplitWords.setChecked(False) - self.sbNameMatchSearchThresh.setValue(self.settings.id_series_match_identify_thresh) + self.sbNameMatchSearchThresh.setValue(self.options["identifier"]["series_match_identify_thresh"]) self.leSearchString.setEnabled(False) - self.cbxSaveOnLowConfidence.setChecked(self.settings.save_on_low_confidence) - self.cbxDontUseYear.setChecked(self.settings.dont_use_year_when_identifying) - self.cbxAssumeIssueOne.setChecked(self.settings.assume_1_if_no_issue_num) - self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.settings.ignore_leading_numbers_in_filename) - self.cbxRemoveAfterSuccess.setChecked(self.settings.remove_archive_after_successful_match) - self.cbxAutoImprint.setChecked(self.settings.auto_imprint) + self.cbxSaveOnLowConfidence.setChecked(self.options["autotag"]["save_on_low_confidence"]) + self.cbxDontUseYear.setChecked(self.options["autotag"]["dont_use_year_when_identifying"]) + self.cbxAssumeIssueOne.setChecked(self.options["autotag"]["assume_1_if_no_issue_num"]) + self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.options["autotag"]["ignore_leading_numbers_in_filename"]) + self.cbxRemoveAfterSuccess.setChecked(self.options["autotag"]["remove_archive_after_successful_match"]) + self.cbxWaitForRateLimit.setChecked(self.options["autotag"]["wait_and_retry_on_rate_limit"]) + self.cbxAutoImprint.setChecked(self.options["comicvine"]["auto_imprint"]) nlmt_tip = """The Name Match Ratio Threshold: Auto-Identify is for eliminating automatic search matches that are too long compared to your series name search. The lower @@ -72,8 +73,9 @@ class AutoTagStartWindow(QtWidgets.QDialog): self.assume_issue_one = False self.ignore_leading_digits_in_filename = False self.remove_after_success = False + self.wait_and_retry_on_rate_limit = False self.search_string = "" - self.name_length_match_tolerance = self.settings.id_series_match_search_thresh + self.name_length_match_tolerance = self.options["comicvine"]["series_match_search_thresh"] self.split_words = self.cbxSplitWords.isChecked() def search_string_toggle(self) -> None: @@ -89,14 +91,16 @@ class AutoTagStartWindow(QtWidgets.QDialog): self.ignore_leading_digits_in_filename = self.cbxIgnoreLeadingDigitsInFilename.isChecked() self.remove_after_success = self.cbxRemoveAfterSuccess.isChecked() self.name_length_match_tolerance = self.sbNameMatchSearchThresh.value() + self.wait_and_retry_on_rate_limit = self.cbxWaitForRateLimit.isChecked() self.split_words = self.cbxSplitWords.isChecked() # persist some settings - self.settings.save_on_low_confidence = self.auto_save_on_low - self.settings.dont_use_year_when_identifying = self.dont_use_year - self.settings.assume_1_if_no_issue_num = self.assume_issue_one - self.settings.ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename - self.settings.remove_archive_after_successful_match = self.remove_after_success + self.options["autotag"]["save_on_low_confidence"] = self.auto_save_on_low + self.options["autotag"]["dont_use_year_when_identifying"] = self.dont_use_year + self.options["autotag"]["assume_1_if_no_issue_num"] = self.assume_issue_one + self.options["autotag"]["ignore_leading_numbers_in_filename"] = self.ignore_leading_digits_in_filename + self.options["autotag"]["remove_archive_after_successful_match"] = self.remove_after_success + self.options["autotag"]["wait_and_retry_on_rate_limit"] = self.wait_and_retry_on_rate_limit if self.cbxSpecifySearchString.isChecked(): self.search_string = self.leSearchString.text() diff --git a/comictaggerlib/cbltransformer.py b/comictaggerlib/cbltransformer.py index 34194c3..c6d9726 100644 --- a/comictaggerlib/cbltransformer.py +++ b/comictaggerlib/cbltransformer.py @@ -18,15 +18,15 @@ from __future__ import annotations import logging from comicapi.genericmetadata import CreditMetadata, GenericMetadata -from comictaggerlib.settings import ComicTaggerSettings +from comictaggerlib import settings logger = logging.getLogger(__name__) class CBLTransformer: - def __init__(self, metadata: GenericMetadata, settings: ComicTaggerSettings) -> None: + def __init__(self, metadata: GenericMetadata, options: settings.OptionValues) -> None: self.metadata = metadata - self.settings = settings + self.options = options def apply(self) -> GenericMetadata: # helper funcs @@ -40,7 +40,7 @@ class CBLTransformer: for item in items: append_to_tags_if_unique(item) - if self.settings.assume_lone_credit_is_primary: + if self.options["cbl"]["assume_lone_credit_is_primary"]: # helper def set_lone_primary(role_list: list[str]) -> tuple[CreditMetadata | None, int]: @@ -67,19 +67,19 @@ class CBLTransformer: c["primary"] = False self.metadata.add_credit(c["person"], "Artist", True) - if self.settings.copy_characters_to_tags: + if self.options["cbl"]["copy_characters_to_tags"]: add_string_list_to_tags(self.metadata.characters) - if self.settings.copy_teams_to_tags: + if self.options["cbl"]["copy_teams_to_tags"]: add_string_list_to_tags(self.metadata.teams) - if self.settings.copy_locations_to_tags: + if self.options["cbl"]["copy_locations_to_tags"]: add_string_list_to_tags(self.metadata.locations) - if self.settings.copy_storyarcs_to_tags: + if self.options["cbl"]["copy_storyarcs_to_tags"]: add_string_list_to_tags(self.metadata.story_arc) - if self.settings.copy_notes_to_comments: + if self.options["cbl"]["copy_notes_to_comments"]: if self.metadata.notes is not None: if self.metadata.comments is None: self.metadata.comments = "" @@ -88,7 +88,7 @@ class CBLTransformer: if self.metadata.notes not in self.metadata.comments: self.metadata.comments += self.metadata.notes - if self.settings.copy_weblink_to_comments: + if self.options["cbl"]["copy_weblink_to_comments"]: if self.metadata.web_link is not None: if self.metadata.comments is None: self.metadata.comments = "" diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index 3a13e2c..f2424fe 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -16,7 +16,6 @@ # limitations under the License. from __future__ import annotations -import argparse import json import logging import os @@ -28,21 +27,18 @@ from pprint import pprint from comicapi import utils from comicapi.comicarchive import ComicArchive, MetaDataStyle from comicapi.genericmetadata import GenericMetadata -from comictaggerlib import ctversion +from comictaggerlib import ctversion, settings from comictaggerlib.cbltransformer import CBLTransformer 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.settings import ComicTaggerSettings from comictalker.talkerbase import ComicTalker, TalkerError logger = logging.getLogger(__name__) -def actual_issue_data_fetch( - issue_id: int, settings: ComicTaggerSettings, opts: argparse.Namespace, talker_api: ComicTalker -) -> GenericMetadata: +def actual_issue_data_fetch(issue_id: int, options: settings.OptionValues, talker_api: ComicTalker) -> GenericMetadata: # now get the particular issue data try: ct_md = talker_api.fetch_comic_data(issue_id) @@ -50,15 +46,15 @@ def actual_issue_data_fetch( logger.exception(f"Error retrieving issue details. Save aborted.\n{e}") return GenericMetadata() - if settings.apply_cbl_transform_on_cv_import: - ct_md = CBLTransformer(ct_md, settings).apply() + if options["cbl"]["apply_cbl_transform_on_cv_import"]: + ct_md = CBLTransformer(ct_md, options).apply() return ct_md -def actual_metadata_save(ca: ComicArchive, opts: argparse.Namespace, md: GenericMetadata) -> bool: - if not opts.dryrun: - for metadata_style in opts.type: +def actual_metadata_save(ca: ComicArchive, options: settings.OptionValues, md: GenericMetadata) -> bool: + if not options["runtime"]["dryrun"]: + for metadata_style in options["runtime"]["type"]: # write out the new data if not ca.write_metadata(md, metadata_style): logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style]) @@ -67,7 +63,7 @@ def actual_metadata_save(ca: ComicArchive, opts: argparse.Namespace, md: Generic print("Save complete.") logger.info("Save complete.") else: - if opts.terse: + if options["runtime"]["terse"]: logger.info("dry-run option was set, so nothing was written") print("dry-run option was set, so nothing was written") else: @@ -80,8 +76,7 @@ def actual_metadata_save(ca: ComicArchive, opts: argparse.Namespace, md: Generic def display_match_set_for_choice( label: str, match_set: MultipleMatch, - opts: argparse.Namespace, - settings: ComicTaggerSettings, + options: settings.OptionValues, talker_api: ComicTalker, ) -> None: print(f"{match_set.ca.path} -- {label}:") @@ -102,7 +97,7 @@ def display_match_set_for_choice( m["issue_title"], ) ) - if opts.interactive: + if options["runtime"]["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": @@ -111,9 +106,9 @@ def display_match_set_for_choice( # save the data! # we know at this point, that the file is all good to go ca = match_set.ca - md = create_local_metadata(opts, ca, settings) - ct_md = actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"], settings, opts, talker_api) - if opts.overwrite: + md = create_local_metadata(ca, options) + ct_md = actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"], options, talker_api) + if options["comicvine"]["clear_metadata_on_import"]: md = ct_md else: notes = ( @@ -122,17 +117,17 @@ def display_match_set_for_choice( ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if opts.auto_imprint: + if options["comicvine"]["auto_imprint"]: md.fix_publisher() - actual_metadata_save(ca, opts, md) + actual_metadata_save(ca, options, md) def post_process_matches( - match_results: OnlineMatchResults, opts: argparse.Namespace, settings: ComicTaggerSettings, talker_api: ComicTalker + match_results: OnlineMatchResults, options: settings.OptionValues, talker_api: ComicTalker ) -> None: # now go through the match results - if opts.show_save_summary: + if options["runtime"]["show_save_summary"]: if len(match_results.good_matches) > 0: print("\nSuccessful matches:\n------------------") for f in match_results.good_matches: @@ -153,14 +148,14 @@ def post_process_matches( for f in match_results.fetch_data_failures: print(f) - if not opts.show_save_summary and not opts.interactive: + if not options["runtime"]["show_save_summary"] and not options["runtime"]["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------------------") for match_set in match_results.multiple_matches: - display_match_set_for_choice("Multiple high-confidence matches", match_set, opts, settings, talker_api) + display_match_set_for_choice("Multiple high-confidence matches", match_set, options, talker_api) if len(match_results.low_confidence_matches) > 0: print("\nArchives with low-confidence matches:\n------------------") @@ -170,40 +165,40 @@ def post_process_matches( else: label = "Multiple low-confidence matches" - display_match_set_for_choice(label, match_set, opts, settings, talker_api) + display_match_set_for_choice(label, match_set, options, talker_api) -def cli_mode(opts: argparse.Namespace, settings: ComicTaggerSettings, talker_api: ComicTalker) -> None: - if len(opts.file_list) < 1: +def cli_mode(options: settings.OptionValues, talker_api: ComicTalker) -> None: + if len(options["runtime"]["file_list"]) < 1: logger.error("You must specify at least one filename. Use the -h option for more info") return match_results = OnlineMatchResults() - for f in opts.file_list: - process_file_cli(f, opts, settings, talker_api, match_results) + for f in options["runtime"]["file_list"]: + process_file_cli(f, options, talker_api, match_results) sys.stdout.flush() - post_process_matches(match_results, opts, settings, talker_api) + post_process_matches(match_results, options, talker_api) -def create_local_metadata(opts: argparse.Namespace, ca: ComicArchive, settings: ComicTaggerSettings) -> GenericMetadata: +def create_local_metadata(ca: ComicArchive, options: settings.OptionValues) -> GenericMetadata: md = GenericMetadata() md.set_default_page_list(ca.get_number_of_pages()) # now, overlay the parsed filename info - if opts.parse_filename: + if options["runtime"]["parse_filename"]: f_md = ca.metadata_from_filename( - settings.complicated_parser, - settings.remove_c2c, - settings.remove_fcbd, - settings.remove_publisher, - opts.split_words, + options["filename"]["complicated_parser"], + options["filename"]["remove_c2c"], + options["filename"]["remove_fcbd"], + options["filename"]["remove_publisher"], + options["runtime"]["split_words"], ) md.overlay(f_md) - for metadata_style in opts.type: + for metadata_style in options["runtime"]["type"]: if ca.has_metadata(metadata_style): try: t_md = ca.read_metadata(metadata_style) @@ -213,21 +208,17 @@ def create_local_metadata(opts: argparse.Namespace, ca: ComicArchive, settings: logger.error("Failed to load metadata for %s: %s", ca.path, e) # finally, use explicit stuff - md.overlay(opts.metadata) + md.overlay(options["runtime"]["metadata"]) return md def process_file_cli( - filename: str, - opts: argparse.Namespace, - settings: ComicTaggerSettings, - talker_api: ComicTalker, - match_results: OnlineMatchResults, + filename: str, options: settings.OptionValues, talker_api: ComicTalker, match_results: OnlineMatchResults ) -> None: - batch_mode = len(opts.file_list) > 1 + batch_mode = len(options["runtime"]["file_list"]) > 1 - ca = ComicArchive(filename, settings.rar_exe_path, str(graphics_path / "nocover.png")) + ca = ComicArchive(filename, options["general"]["rar_exe_path"], str(graphics_path / "nocover.png")) if not os.path.lexists(filename): logger.error("Cannot find %s", filename) @@ -237,7 +228,12 @@ def process_file_cli( logger.error("Sorry, but %s is not a comic archive!", filename) return - if not ca.is_writable() and (opts.delete or opts.copy or opts.save or opts.rename): + if not ca.is_writable() and ( + options["commands"]["delete"] + or options["commands"]["copy"] + or options["commands"]["save"] + or options["commands"]["rename"] + ): logger.error("This archive is not writable") return @@ -249,9 +245,9 @@ def process_file_cli( if ca.has_comet(): has[MetaDataStyle.COMET] = True - if opts.print: + if options["commands"]["print"]: - if not opts.type: + if not options["runtime"]["type"]: page_count = ca.get_number_of_pages() brief = "" @@ -284,49 +280,49 @@ def process_file_cli( print(brief) - if opts.terse: + if options["runtime"]["terse"]: return print() - if not opts.type or MetaDataStyle.CIX in opts.type: + if not options["runtime"]["type"] or MetaDataStyle.CIX in options["runtime"]["type"]: if has[MetaDataStyle.CIX]: print("--------- ComicRack tags ---------") try: - if opts.raw: + if options["runtime"]["raw"]: print(ca.read_raw_cix()) else: print(ca.read_cix()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - if not opts.type or MetaDataStyle.CBI in opts.type: + if not options["runtime"]["type"] or MetaDataStyle.CBI in options["runtime"]["type"]: if has[MetaDataStyle.CBI]: print("------- ComicBookLover tags -------") try: - if opts.raw: + if options["runtime"]["raw"]: pprint(json.loads(ca.read_raw_cbi())) else: print(ca.read_cbi()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - if not opts.type or MetaDataStyle.COMET in opts.type: + if not options["runtime"]["type"] or MetaDataStyle.COMET in options["runtime"]["type"]: if has[MetaDataStyle.COMET]: print("----------- CoMet tags -----------") try: - if opts.raw: + if options["runtime"]["raw"]: print(ca.read_raw_comet()) else: print(ca.read_comet()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - elif opts.delete: - for metadata_style in opts.type: + elif options["commands"]["delete"]: + for metadata_style in options["runtime"]["type"]: style_name = MetaDataStyle.name[metadata_style] if has[metadata_style]: - if not opts.dryrun: + if not options["runtime"]["dryrun"]: if not ca.remove_metadata(metadata_style): print(f"{filename}: Tag removal seemed to fail!") else: @@ -336,27 +332,27 @@ def process_file_cli( else: print(f"{filename}: This archive doesn't have {style_name} tags to remove.") - elif opts.copy is not None: - for metadata_style in opts.type: + elif options["commands"]["copy"] is not None: + for metadata_style in options["runtime"]["type"]: dst_style_name = MetaDataStyle.name[metadata_style] - if opts.no_overwrite and has[metadata_style]: + if options["runtime"]["no_overwrite"] and has[metadata_style]: print(f"{filename}: Already has {dst_style_name} tags. Not overwriting.") return - if opts.copy == metadata_style: + if options["commands"]["copy"] == metadata_style: print(f"{filename}: Destination and source are same: {dst_style_name}. Nothing to do.") return - src_style_name = MetaDataStyle.name[opts.copy] - if has[opts.copy]: - if not opts.dryrun: + src_style_name = MetaDataStyle.name[options["commands"]["copy"]] + if has[options["commands"]["copy"]]: + if not options["runtime"]["dryrun"]: try: - md = ca.read_metadata(opts.copy) + md = ca.read_metadata(options["commands"]["copy"]) except Exception as e: md = GenericMetadata() logger.error("Failed to load metadata for %s: %s", ca.path, e) - if settings.apply_cbl_transform_on_bulk_operation and metadata_style == MetaDataStyle.CBI: - md = CBLTransformer(md, settings).apply() + if options["apply_cbl_transform_on_bulk_operation"] and metadata_style == MetaDataStyle.CBI: + md = CBLTransformer(md, options).apply() if not ca.write_metadata(md, metadata_style): print(f"{filename}: Tag copy seemed to fail!") @@ -367,10 +363,10 @@ def process_file_cli( else: print(f"{filename}: This archive doesn't have {src_style_name} tags to copy.") - elif opts.save: + elif options["commands"]["save"]: - if opts.no_overwrite: - for metadata_style in opts.type: + if options["runtime"]["no_overwrite"]: + for metadata_style in options["runtime"]["type"]: if has[metadata_style]: print(f"{filename}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.") return @@ -378,39 +374,39 @@ def process_file_cli( if batch_mode: print(f"Processing {ca.path}...") - md = create_local_metadata(opts, ca, settings) + md = create_local_metadata(ca, options) if md.issue is None or md.issue == "": - if opts.assume_issue_one: + if options["runtime"]["assume_issue_one"]: md.issue = "1" # now, search online - if opts.online: - if opts.issue_id is not None: + if options["runtime"]["online"]: + if options["runtime"]["issue_id"] is not None: # we were given the actual issue ID to search with try: - ct_md = talker_api.fetch_comic_data(opts.issue_id) + ct_md = talker_api.fetch_comic_data(options["runtime"]["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 if ct_md is None: - logger.error("No match for ID %s was found.", opts.issue_id) + logger.error("No match for ID %s was found.", options["runtime"]["issue_id"]) match_results.no_matches.append(str(ca.path.absolute())) return - if settings.apply_cbl_transform_on_cv_import: - ct_md = CBLTransformer(ct_md, settings).apply() + if options["cbl"]["apply_cbl_transform_on_cv_import"]: + ct_md = CBLTransformer(ct_md, options).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 - ii = IssueIdentifier(ca, settings, talker_api) + ii = IssueIdentifier(ca, options, talker_api) def myoutput(text: str) -> None: - if opts.verbose: + if options["runtime"]["verbose"]: IssueIdentifier.default_write_output(text) # use our overlaid MD struct to search @@ -450,7 +446,7 @@ def process_file_cli( logger.error("Online search: Multiple good matches. Save aborted") match_results.multiple_matches.append(MultipleMatch(ca, matches)) return - if low_confidence and opts.abort_on_low_confidence: + if low_confidence and options["runtime"]["abort_on_low_confidence"]: logger.error("Online search: Low confidence match. Save aborted") match_results.low_confidence_matches.append(MultipleMatch(ca, matches)) return @@ -462,12 +458,12 @@ def process_file_cli( # we got here, so we have a single match # now get the particular issue data - ct_md = actual_issue_data_fetch(matches[0]["issue_id"], settings, opts, talker_api) + ct_md = actual_issue_data_fetch(matches[0]["issue_id"], options, talker_api) if ct_md.is_empty: match_results.fetch_data_failures.append(str(ca.path.absolute())) return - if opts.overwrite: + if options["comicvine"]["clear_metadata_on_import"]: md = ct_md else: notes = ( @@ -476,29 +472,29 @@ def process_file_cli( ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if opts.auto_imprint: + if options["comicvine"]["auto_imprint"]: md.fix_publisher() # ok, done building our metadata. time to save - if not actual_metadata_save(ca, opts, md): + if not actual_metadata_save(ca, options, md): match_results.write_failures.append(str(ca.path.absolute())) else: match_results.good_matches.append(str(ca.path.absolute())) - elif opts.rename: + elif options["commands"]["rename"]: original_path = ca.path msg_hdr = "" if batch_mode: msg_hdr = f"{ca.path}: " - md = create_local_metadata(opts, ca, settings) + md = create_local_metadata(ca, options) if md.series is None: logger.error(msg_hdr + "Can't rename without series name") return new_ext = "" # default - if settings.rename_extension_based_on_archive: + if options["filename"]["rename_set_extension_based_on_archive"]: if ca.is_sevenzip(): new_ext = ".cb7" elif ca.is_zip(): @@ -506,11 +502,11 @@ def process_file_cli( elif ca.is_rar(): new_ext = ".cbr" - renamer = FileRenamer(md, platform="universal" if settings.rename_strict else "auto") - renamer.set_template(settings.rename_template) - renamer.set_issue_zero_padding(settings.rename_issue_number_padding) - renamer.set_smart_cleanup(settings.rename_use_smart_string_cleanup) - renamer.move = settings.rename_move_dir + renamer = FileRenamer(md, platform="universal" if options[filename]["rename_strict"] else "auto") + renamer.set_template(options[filename]["rename_template"]) + renamer.set_issue_zero_padding(options[filename]["rename_issue_number_padding"]) + renamer.set_smart_cleanup(options[filename]["rename_use_smart_string_cleanup"]) + renamer.move = options[filename]["rename_move_to_dir"] try: new_name = renamer.determine_name(ext=new_ext) @@ -522,13 +518,17 @@ def process_file_cli( "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", - settings.rename_template, + options[filename]["rename_template"], ) return except Exception: - logger.exception("Formatter failure: %s metadata: %s", settings.rename_template, renamer.metadata) + logger.exception( + "Formatter failure: %s metadata: %s", options[filename]["rename_template"], renamer.metadata + ) - folder = get_rename_dir(ca, settings.rename_dir if settings.rename_move_dir else None) + folder = get_rename_dir( + ca, options[filename]["rename_dir"] if options[filename]["rename_move_to_dir"] else None + ) full_path = folder / new_name @@ -537,7 +537,7 @@ def process_file_cli( return suffix = "" - if not opts.dryrun: + if not options["runtime"]["dryrun"]: # rename the file try: ca.rename(utils.unique_file(full_path)) @@ -548,7 +548,7 @@ def process_file_cli( print(f"renamed '{original_path.name}' -> '{new_name}' {suffix}") - elif opts.export_to_zip: + elif options["commands"]["export_to_zip"]: msg_hdr = "" if batch_mode: msg_hdr = f"{ca.path}: " @@ -560,7 +560,7 @@ def process_file_cli( filename_path = pathlib.Path(filename).absolute() new_file = filename_path.with_suffix(".cbz") - if opts.abort_on_conflict and new_file.exists(): + if options["runtime"]["abort_on_conflict"] and new_file.exists(): print(msg_hdr + f"{new_file.name} already exists in the that folder.") return @@ -568,10 +568,10 @@ def process_file_cli( delete_success = False export_success = False - if not opts.dryrun: + if not options["runtime"]["dryrun"]: if ca.export_as_zip(new_file): export_success = True - if opts.delete_after_zip_export: + if options["runtime"]["delete_after_zip_export"]: try: filename_path.unlink(missing_ok=True) delete_success = True @@ -583,7 +583,7 @@ def process_file_cli( new_file.unlink(missing_ok=True) else: msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}" - if opts.delete_after_zip_export: + if options["runtime"]["delete_after_zip_export"]: msg += " and delete original." print(msg) return @@ -591,7 +591,7 @@ def process_file_cli( msg = msg_hdr if export_success: msg += f"Archive exported successfully to: {os.path.split(new_file)[1]}" - if opts.delete_after_zip_export and delete_success: + if options["runtime"]["delete_after_zip_export"] and delete_success: msg += " (Original deleted) " else: msg += "Archive failed to export!" diff --git a/comictaggerlib/coverimagewidget.py b/comictaggerlib/coverimagewidget.py index ecd19a4..c7c6a51 100644 --- a/comictaggerlib/coverimagewidget.py +++ b/comictaggerlib/coverimagewidget.py @@ -20,6 +20,7 @@ TODO: This should be re-factored using subclasses! from __future__ import annotations import logging +import pathlib from PyQt5 import QtCore, QtGui, QtWidgets, uic @@ -63,16 +64,26 @@ class CoverImageWidget(QtWidgets.QWidget): image_fetch_complete = QtCore.pyqtSignal(QtCore.QByteArray) def __init__( - self, parent: QtWidgets.QWidget, talker_api: ComicTalker, mode: int, expand_on_click: bool = True + self, + parent: QtWidgets.QWidget, + mode: int, + cache_folder: pathlib.Path | None, + talker_api: ComicTalker | None, + expand_on_click: bool = True, ) -> None: super().__init__(parent) - self.cover_fetcher = ImageFetcher() + if mode not in (self.AltCoverMode, self.URLMode) or cache_folder is None: + self.cover_fetcher = None + self.talker_api = None + else: + self.cover_fetcher = ImageFetcher(cache_folder) + self.talker_api = None uic.loadUi(ui_path / "coverimagewidget.ui", self) reduce_widget_font_size(self.label) - self.talker_api = talker_api + self.cache_folder = cache_folder self.mode: int = mode self.page_loader: PageLoader | None = None self.showControls = True @@ -221,8 +232,9 @@ class CoverImageWidget(QtWidgets.QWidget): self.label.setText(f"Page {self.imageIndex + 1} (of {self.imageCount})") def load_url(self) -> None: + assert isinstance(self.cache_folder, pathlib.Path) self.load_default() - self.cover_fetcher = ImageFetcher() + self.cover_fetcher = ImageFetcher(self.cache_folder) ImageFetcher.image_fetch_complete = self.image_fetch_complete.emit self.cover_fetcher.fetch(self.url_list[self.imageIndex]) diff --git a/comictaggerlib/exportwindow.py b/comictaggerlib/exportwindow.py index e962bbd..a3f8f73 100644 --- a/comictaggerlib/exportwindow.py +++ b/comictaggerlib/exportwindow.py @@ -19,7 +19,6 @@ import logging from PyQt5 import QtCore, QtWidgets, uic -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.ui import ui_path logger = logging.getLogger(__name__) @@ -32,7 +31,7 @@ class ExportConflictOpts: class ExportWindow(QtWidgets.QDialog): - def __init__(self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings, msg: str) -> None: + def __init__(self, parent: QtWidgets.QWidget, msg: str) -> None: super().__init__(parent) uic.loadUi(ui_path / "exportwindow.ui", self) @@ -42,8 +41,6 @@ class ExportWindow(QtWidgets.QDialog): QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint) ) - self.settings = settings - self.cbxDeleteOriginal.setChecked(False) self.cbxAddToList.setChecked(True) self.radioDontCreate.setChecked(True) diff --git a/comictaggerlib/fileselectionlist.py b/comictaggerlib/fileselectionlist.py index f883d38..7eb0116 100644 --- a/comictaggerlib/fileselectionlist.py +++ b/comictaggerlib/fileselectionlist.py @@ -24,9 +24,9 @@ from PyQt5 import QtCore, QtWidgets, uic from comicapi import utils from comicapi.comicarchive import ComicArchive +from comictaggerlib import settings from comictaggerlib.graphics import graphics_path from comictaggerlib.optionalmsgdialog import OptionalMessageDialog -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import center_window_on_parent, reduce_widget_font_size @@ -59,14 +59,14 @@ class FileSelectionList(QtWidgets.QWidget): def __init__( self, parent: QtWidgets.QWidget, - settings: ComicTaggerSettings, + options: settings.OptionValues, dirty_flag_verification: Callable[[str, str], bool], ) -> None: super().__init__(parent) uic.loadUi(ui_path / "fileselectionlist.ui", self) - self.settings = settings + self.options = options reduce_widget_font_size(self.twList) @@ -227,7 +227,7 @@ class FileSelectionList(QtWidgets.QWidget): else: QtWidgets.QMessageBox.information(self, "File/Folder Open", "No readable comic archives were found.") - if rar_added and not utils.which(self.settings.rar_exe_path or "rar"): + if rar_added and not utils.which(self.options["general"]["rar_exe_path"] or "rar"): self.rar_ro_message() self.twList.setSortingEnabled(True) @@ -281,7 +281,7 @@ class FileSelectionList(QtWidgets.QWidget): if self.is_list_dupe(path): return self.get_current_list_row(path) - ca = ComicArchive(path, self.settings.rar_exe_path, str(graphics_path / "nocover.png")) + ca = ComicArchive(path, self.options["general"]["rar_exe_path"], str(graphics_path / "nocover.png")) if ca.seems_to_be_a_comic_archive(): row: int = self.twList.rowCount() diff --git a/comictaggerlib/gui.py b/comictaggerlib/gui.py new file mode 100644 index 0000000..1212744 --- /dev/null +++ b/comictaggerlib/gui.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import logging.handlers +import os +import platform +import sys +import traceback +import types + +from comictaggerlib import settings +from comictaggerlib.graphics import graphics_path +from comictalker.talkerbase import ComicTalker + +logger = logging.getLogger("comictagger") +try: + qt_available = True + from PyQt5 import QtCore, QtGui, QtWidgets + + def show_exception_box(log_msg: str) -> None: + """Checks if a QApplication instance is available and shows a messagebox with the exception message. + If unavailable (non-console application), log an additional notice. + """ + if QtWidgets.QApplication.instance() is not None: + errorbox = QtWidgets.QMessageBox() + errorbox.setText(log_msg) + errorbox.exec() + QtWidgets.QApplication.exit(1) + else: + logger.debug("No QApplication instance available.") + + class UncaughtHook(QtCore.QObject): + _exception_caught = QtCore.pyqtSignal(object) + + def __init__(self) -> None: + super().__init__() + + # this registers the exception_hook() function as hook with the Python interpreter + sys.excepthook = self.exception_hook + + # connect signal to execute the message box function always on main thread + self._exception_caught.connect(show_exception_box) + + def exception_hook( + self, exc_type: type[BaseException], exc_value: BaseException, exc_traceback: types.TracebackType | None + ) -> None: + """Function handling uncaught exceptions. + It is triggered each time an uncaught exception occurs. + """ + if issubclass(exc_type, KeyboardInterrupt): + # ignore keyboard interrupt to support console applications + sys.__excepthook__(exc_type, exc_value, exc_traceback) + else: + exc_info = (exc_type, exc_value, exc_traceback) + trace_back = "".join(traceback.format_tb(exc_traceback)) + log_msg = f"{exc_type.__name__}: {exc_value}\n\n{trace_back}" + logger.critical("Uncaught exception: %s: %s", exc_type.__name__, exc_value, exc_info=exc_info) + + # trigger message box show + self._exception_caught.emit(f"Oops. An unexpected error occurred:\n{log_msg}") + + qt_exception_hook = UncaughtHook() + from comictaggerlib.taggerwindow import TaggerWindow + + class Application(QtWidgets.QApplication): + openFileRequest = QtCore.pyqtSignal(QtCore.QUrl, name="openfileRequest") + + # Handles "Open With" from Finder on macOS + def event(self, event: QtCore.QEvent) -> bool: + if event.type() == QtCore.QEvent.FileOpen: + logger.info(event.url().toLocalFile()) + self.openFileRequest.emit(event.url()) + return True + return super().event(event) + +except ImportError as e: + + def show_exception_box(log_msg: str) -> None: + ... + + logger.error(str(e)) + qt_available = False + + +def open_tagger_window( + talker_api: ComicTalker, options: settings.OptionValues, gui_exception: Exception | None +) -> None: + os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" + args = [] + if options["runtime"]["darkmode"]: + args.extend(["-platform", "windows:darkmode=2"]) + args.extend(sys.argv) + app = Application(args) + if gui_exception is not None: + trace_back = "".join(traceback.format_tb(gui_exception.__traceback__)) + log_msg = f"{type(gui_exception).__name__}: {gui_exception}\n\n{trace_back}" + show_exception_box(f"{log_msg}") + raise SystemExit(1) + + # needed to catch initial open file events (macOS) + app.openFileRequest.connect(lambda x: options["runtime"]["files"].append(x.toLocalFile())) + + if platform.system() == "Darwin": + # Set the MacOS dock icon + app.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) + + if platform.system() == "Windows": + # For pure python, tell windows that we're not python, + # so we can have our own taskbar icon + import ctypes + + myappid = "comictagger" # arbitrary string + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # type: ignore[attr-defined] + # force close of console window + swp_hidewindow = 0x0080 + console_wnd = ctypes.windll.kernel32.GetConsoleWindow() # type: ignore[attr-defined] + if console_wnd != 0: + ctypes.windll.user32.SetWindowPos(console_wnd, None, 0, 0, 0, 0, swp_hidewindow) # type: ignore[attr-defined] + + if platform.system() != "Linux": + img = QtGui.QPixmap(str(graphics_path / "tags.png")) + + splash = QtWidgets.QSplashScreen(img) + splash.show() + splash.raise_() + QtWidgets.QApplication.processEvents() + + try: + tagger_window = TaggerWindow(options["runtime"]["files"], options, talker_api) + tagger_window.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) + tagger_window.show() + + # Catch open file events (macOS) + app.openFileRequest.connect(tagger_window.open_file_event) + + if platform.system() != "Linux": + splash.finish(tagger_window) + + sys.exit(app.exec()) + except Exception: + logger.exception("GUI mode failed") + QtWidgets.QMessageBox.critical( + QtWidgets.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc() + ) diff --git a/comictaggerlib/imagefetcher.py b/comictaggerlib/imagefetcher.py index 214f2b8..4f07c4f 100644 --- a/comictaggerlib/imagefetcher.py +++ b/comictaggerlib/imagefetcher.py @@ -18,6 +18,7 @@ from __future__ import annotations import datetime import logging import os +import pathlib import shutil import sqlite3 as lite import tempfile @@ -25,7 +26,6 @@ import tempfile import requests from comictaggerlib import ctversion -from comictaggerlib.settings import ComicTaggerSettings try: from PyQt5 import QtCore, QtNetwork @@ -49,11 +49,10 @@ class ImageFetcher: image_fetch_complete = fetch_complete - def __init__(self) -> None: + def __init__(self, cache_folder: pathlib.Path) -> None: - self.settings_folder = ComicTaggerSettings.get_settings_folder() - self.db_file = os.path.join(self.settings_folder, "image_url_cache.db") - self.cache_folder = os.path.join(self.settings_folder, "image_cache") + self.db_file = cache_folder / "image_url_cache.db" + self.cache_folder = cache_folder / "image_cache" self.user_data = None self.fetched_url = "" diff --git a/comictaggerlib/imagehasher.py b/comictaggerlib/imagehasher.py old mode 100755 new mode 100644 diff --git a/comictaggerlib/issueidentifier.py b/comictaggerlib/issueidentifier.py index 3ed87c3..7b30fb9 100644 --- a/comictaggerlib/issueidentifier.py +++ b/comictaggerlib/issueidentifier.py @@ -26,11 +26,10 @@ from comicapi import utils from comicapi.comicarchive import ComicArchive from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString +from comictaggerlib import settings from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException from comictaggerlib.imagehasher import ImageHasher from comictaggerlib.resulttypes import IssueResult -from comictaggerlib.settings import ComicTaggerSettings -from comictalker.talker_utils import parse_date_str from comictalker.talkerbase import ComicTalker, TalkerError logger = logging.getLogger(__name__) @@ -73,8 +72,8 @@ class IssueIdentifier: result_one_good_match = 4 result_multiple_good_matches = 5 - def __init__(self, comic_archive: ComicArchive, settings: ComicTaggerSettings, talker_api: ComicTalker) -> None: - self.settings = settings + def __init__(self, comic_archive: ComicArchive, options: settings.OptionValues, talker_api: ComicTalker) -> None: + self.options = options self.talker_api = talker_api self.comic_archive: ComicArchive = comic_archive self.image_hasher = 1 @@ -97,10 +96,10 @@ class IssueIdentifier: # used to eliminate series names that are too long based on our search # string - self.series_match_thresh = settings.id_series_match_identify_thresh + self.series_match_thresh = options["identifier"]["series_match_identify_thresh"] # used to eliminate unlikely publishers - self.publisher_filter = [s.strip().casefold() for s in settings.id_publisher_filter.split(",")] + self.publisher_filter = [s.strip().casefold() for s in options["identifier"]["publisher_filter"]] self.additional_metadata = GenericMetadata() self.output_function: Callable[[str], None] = IssueIdentifier.default_write_output @@ -202,10 +201,10 @@ class IssueIdentifier: # try to get some metadata from filename md_from_filename = ca.metadata_from_filename( - self.settings.complicated_parser, - self.settings.remove_c2c, - self.settings.remove_fcbd, - self.settings.remove_publisher, + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], ) working_md = md_from_filename.copy() @@ -256,7 +255,9 @@ class IssueIdentifier: return Score(score=0, url="", hash=0) try: - url_image_data = ImageFetcher().fetch(primary_thumb_url, blocking=True) + url_image_data = ImageFetcher(self.options["runtime"]["config"].user_cache_dir).fetch( + primary_thumb_url, blocking=True + ) except ImageFetcherException as e: self.log_msg("Network issue while fetching cover image from Comic Vine. Aborting...") raise IssueIdentifierNetworkError from e @@ -276,7 +277,9 @@ class IssueIdentifier: if use_remote_alternates: for alt_url in alt_urls: try: - alt_url_image_data = ImageFetcher().fetch(alt_url, blocking=True) + alt_url_image_data = ImageFetcher(self.options["runtime"]["config"].user_cache_dir).fetch( + alt_url, blocking=True + ) except ImageFetcherException as e: self.log_msg("Network issue while fetching alt. cover image from Comic Vine. Aborting...") raise IssueIdentifierNetworkError from e @@ -466,7 +469,7 @@ class IssueIdentifier: ) # parse out the cover date - _, month, year = parse_date_str(issue["cover_date"]) + _, month, year = utils.parse_date_str(issue["cover_date"]) # Now check the cover match against the primary image hash_list = [cover_hash] @@ -487,6 +490,7 @@ class IssueIdentifier: use_remote_alternates=False, ) except Exception: + logger.exception("Scoring series failed") self.match_list = [] return self.match_list diff --git a/comictaggerlib/issueselectionwindow.py b/comictaggerlib/issueselectionwindow.py index e1b6e0c..0c5694d 100644 --- a/comictaggerlib/issueselectionwindow.py +++ b/comictaggerlib/issueselectionwindow.py @@ -20,8 +20,8 @@ import logging from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi.issuestring import IssueString +from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import reduce_widget_font_size from comictalker.resulttypes import ComicIssue @@ -44,7 +44,7 @@ class IssueSelectionWindow(QtWidgets.QDialog): def __init__( self, parent: QtWidgets.QWidget, - settings: ComicTaggerSettings, + options: settings.OptionValues, talker_api: ComicTalker, series_id: int, issue_number: str, @@ -53,7 +53,12 @@ class IssueSelectionWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "issueselectionwindow.ui", self) - self.coverWidget = CoverImageWidget(self.coverImageContainer, talker_api, CoverImageWidget.AltCoverMode) + self.coverWidget = CoverImageWidget( + self.coverImageContainer, + CoverImageWidget.AltCoverMode, + options["runtime"]["config"].user_cache_dir, + talker_api, + ) gridlayout = QtWidgets.QGridLayout(self.coverImageContainer) gridlayout.addWidget(self.coverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) @@ -71,7 +76,7 @@ class IssueSelectionWindow(QtWidgets.QDialog): self.series_id = series_id self.issue_id: int | None = None - self.settings = settings + self.options = options self.talker_api = talker_api self.url_fetch_thread = None self.issue_list: list[ComicIssue] = [] diff --git a/comictaggerlib/log.py b/comictaggerlib/log.py new file mode 100644 index 0000000..e022f67 --- /dev/null +++ b/comictaggerlib/log.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import logging.handlers +import pathlib + +logger = logging.getLogger("comictagger") + + +def get_file_handler(filename: pathlib.Path) -> logging.FileHandler: + file_handler = logging.handlers.RotatingFileHandler(filename, encoding="utf-8", backupCount=10) + + if filename.is_file() and filename.stat().st_size > 0: + file_handler.doRollover() + return file_handler + + +def setup_logging(verbose: int, log_dir: pathlib.Path) -> None: + logging.getLogger("comicapi").setLevel(logging.DEBUG) + logging.getLogger("comictaggerlib").setLevel(logging.DEBUG) + + log_file = log_dir / "ComicTagger.log" + log_file.parent.mkdir(parents=True, exist_ok=True) + + stream_handler = logging.StreamHandler() + file_handler = get_file_handler(log_file) + + if verbose > 1: + stream_handler.setLevel(logging.DEBUG) + elif verbose > 0: + stream_handler.setLevel(logging.INFO) + else: + stream_handler.setLevel(logging.WARNING) + + logging.basicConfig( + handlers=[ + stream_handler, + file_handler, + ], + level=logging.WARNING, + format="%(asctime)s | %(name)s | %(levelname)s | %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py old mode 100755 new mode 100644 index 5bd46a2..7c0ea9a --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -15,23 +15,20 @@ # limitations under the License. from __future__ import annotations +import argparse import json import logging.handlers -import os -import pathlib import platform +import pprint import signal import sys -import traceback -import types +from typing import Any import comictalker.comictalkerapi as ct_api from comicapi import utils -from comictaggerlib import cli +from comictaggerlib import cli, settings from comictaggerlib.ctversion import version -from comictaggerlib.graphics import graphics_path -from comictaggerlib.options import parse_cmd_line -from comictaggerlib.settings import ComicTaggerSettings +from comictaggerlib.log import setup_logging from comictalker.talkerbase import TalkerError if sys.version_info < (3, 10): @@ -39,235 +36,138 @@ if sys.version_info < (3, 10): else: import importlib.metadata as importlib_metadata -logger = logging.getLogger("comictagger") -logging.getLogger("comicapi").setLevel(logging.DEBUG) -logging.getLogger("comictaggerlib").setLevel(logging.DEBUG) -logging.getLogger("comictalker").setLevel(logging.DEBUG) -logger.setLevel(logging.DEBUG) - try: - qt_available = True - from PyQt5 import QtCore, QtGui, QtWidgets + from comictaggerlib import gui - def show_exception_box(log_msg: str) -> None: - """Checks if a QApplication instance is available and shows a messagebox with the exception message. - If unavailable (non-console application), log an additional notice. - """ - if QtWidgets.QApplication.instance() is not None: - errorbox = QtWidgets.QMessageBox() - errorbox.setText(log_msg) - errorbox.exec() - QtWidgets.QApplication.exit(1) - else: - logger.debug("No QApplication instance available.") - - class UncaughtHook(QtCore.QObject): - _exception_caught = QtCore.pyqtSignal(object) - - def __init__(self) -> None: - super().__init__() - - # this registers the exception_hook() function as hook with the Python interpreter - sys.excepthook = self.exception_hook - - # connect signal to execute the message box function always on main thread - self._exception_caught.connect(show_exception_box) - - def exception_hook( - self, exc_type: type[BaseException], exc_value: BaseException, exc_traceback: types.TracebackType | None - ) -> None: - """Function handling uncaught exceptions. - It is triggered each time an uncaught exception occurs. - """ - if issubclass(exc_type, KeyboardInterrupt): - # ignore keyboard interrupt to support console applications - sys.__excepthook__(exc_type, exc_value, exc_traceback) - else: - exc_info = (exc_type, exc_value, exc_traceback) - trace_back = "".join(traceback.format_tb(exc_traceback)) - log_msg = f"{exc_type.__name__}: {exc_value}\n\n{trace_back}" - logger.critical("Uncaught exception: %s: %s", exc_type.__name__, exc_value, exc_info=exc_info) - - # trigger message box show - self._exception_caught.emit(f"Oops. An unexpected error occurred:\n{log_msg}") - - qt_exception_hook = UncaughtHook() - from comictaggerlib.taggerwindow import TaggerWindow - - class Application(QtWidgets.QApplication): - openFileRequest = QtCore.pyqtSignal(QtCore.QUrl, name="openfileRequest") - - def event(self, event: QtCore.QEvent) -> bool: - if event.type() == QtCore.QEvent.FileOpen: - logger.info(event.url().toLocalFile()) - self.openFileRequest.emit(event.url()) - return True - return super().event(event) - -except ImportError as e: - - def show_exception_box(log_msg: str) -> None: - ... - - logger.error(str(e)) + qt_available = gui.qt_available +except Exception: qt_available = False - -def rotate(handler: logging.handlers.RotatingFileHandler, filename: pathlib.Path) -> None: - if filename.is_file() and filename.stat().st_size > 0: - handler.doRollover() +logger = logging.getLogger("comictagger") -def update_publishers() -> None: - json_file = ComicTaggerSettings.get_settings_folder() / "publishers.json" +logger.setLevel(logging.DEBUG) + + +def update_publishers(options: dict[str, dict[str, Any]]) -> None: + json_file = options["runtime"]["config"].user_config_dir / "publishers.json" if json_file.exists(): try: utils.update_publishers(json.loads(json_file.read_text("utf-8"))) - except Exception as e: + except Exception: logger.exception("Failed to load publishers from %s", json_file) - show_exception_box(str(e)) + # show_exception_box(str(e)) -def ctmain() -> None: - opts = parse_cmd_line() - settings = ComicTaggerSettings(opts.config_path) +class App: + """docstring for App""" - os.makedirs(ComicTaggerSettings.get_settings_folder() / "logs", exist_ok=True) - stream_handler = logging.StreamHandler() - stream_handler.setLevel(logging.WARNING) - file_handler = logging.handlers.RotatingFileHandler( - ComicTaggerSettings.get_settings_folder() / "logs" / "ComicTagger.log", encoding="utf-8", backupCount=10 - ) - rotate(file_handler, ComicTaggerSettings.get_settings_folder() / "logs" / "ComicTagger.log") - logging.basicConfig( - handlers=[ - stream_handler, - file_handler, - ], - level=logging.WARNING, - format="%(asctime)s | %(name)s | %(levelname)s | %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - ) + def __init__(self) -> None: + self.options: dict[str, dict[str, Any]] = {} + self.initial_arg_parser = settings.initial_cmd_line_parser() - if settings.settings_warning < 4: - print( # noqa: T201 - """ -!!!Warning!!! -The next release will save settings in a different format -NO SETTINGS WILL BE TRANSFERED to the new version. -See https://github.com/comictagger/comictagger/releases/1.5.5 for more information. -""", - file=sys.stderr, - ) + def run(self) -> None: + opts = self.initialize() + self.register_options() + self.parse_options(opts.config) + self.initialize_dirs() - # manage the CV API key - # None comparison is used so that the empty string can unset the value - if opts.cv_api_key is not None or opts.cv_url is not None: - settings.cv_api_key = opts.cv_api_key if opts.cv_api_key is not None else settings.cv_api_key - settings.cv_url = opts.cv_url if opts.cv_url is not None else settings.cv_url - settings.save() - if opts.only_set_cv_key: - print("Key set") # noqa: T201 - return + self.ctmain() - signal.signal(signal.SIGINT, signal.SIG_DFL) + def initialize(self) -> argparse.Namespace: + opts, _ = self.initial_arg_parser.parse_known_args() + assert opts is not None + setup_logging(opts.verbose, opts.config.user_log_dir / "log") + return opts - logger.info( - "ComicTagger Version: %s running on: %s PyInstaller: %s", - version, - platform.system(), - "Yes" if getattr(sys, "frozen", None) else "No", - ) + def register_options(self) -> None: + self.manager = settings.Manager() + settings.register_commandline(self.manager) + settings.register_settings(self.manager) - logger.debug("Installed Packages") - for pkg in sorted(importlib_metadata.distributions(), key=lambda x: x.name): - logger.debug("%s\t%s", pkg.metadata["Name"], pkg.metadata["Version"]) + def parse_options(self, config_paths: settings.ComicTaggerPaths) -> None: + options = self.manager.parse_options(config_paths) + self.options = settings.validate_commandline_options(options, self.manager) + self.options = settings.validate_settings(options, self.manager) - if not qt_available and not opts.no_gui: - opts.no_gui = True - logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.") + def initialize_dirs(self) -> None: + self.options["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) + self.options["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) + self.options["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) + self.options["runtime"]["config"].user_state_dir.mkdir(parents=True, exist_ok=True) + self.options["runtime"]["config"].user_log_dir.mkdir(parents=True, exist_ok=True) + logger.debug("user_data_dir: %s", self.options["runtime"]["config"].user_data_dir) + logger.debug("user_config_dir: %s", self.options["runtime"]["config"].user_config_dir) + logger.debug("user_cache_dir: %s", self.options["runtime"]["config"].user_cache_dir) + logger.debug("user_state_dir: %s", self.options["runtime"]["config"].user_state_dir) + logger.debug("user_log_dir: %s", self.options["runtime"]["config"].user_log_dir) - talker_exception = None - try: - talker_api = ct_api.get_comic_talker("comicvine")( # type: ignore[call-arg] - version=version, - cache_folder=ComicTaggerSettings.get_settings_folder(), - series_match_thresh=settings.id_series_match_search_thresh, - remove_html_tables=settings.remove_html_tables, - use_series_start_as_volume=settings.use_series_start_as_volume, - wait_on_ratelimit=settings.wait_and_retry_on_rate_limit, - api_url=settings.cv_url, - api_key=settings.cv_api_key, - ) - except TalkerError as e: - logger.exception("Unable to load talker") - talker_exception = e - if opts.no_gui: - raise SystemExit(1) + def ctmain(self) -> None: + assert self.options is not None + # options already loaded - utils.load_publishers() - update_publishers() - - if opts.no_gui: - try: - cli.cli_mode(opts, settings, talker_api) - except Exception: - logger.exception("CLI mode failed") - else: - os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - args = [] - if opts.darkmode: - args.extend(["-platform", "windows:darkmode=2"]) - args.extend(sys.argv) - app = Application(args) - if talker_exception is not None: - trace_back = "".join(traceback.format_tb(talker_exception.__traceback__)) - log_msg = f"{type(talker_exception).__name__}: {talker_exception}\n\n{trace_back}" - show_exception_box(f"Unable to load talker: {log_msg}") - raise SystemExit(1) - - # needed to catch initial open file events (macOS) - app.openFileRequest.connect(lambda x: opts.files.append(x.toLocalFile())) - - if platform.system() == "Darwin": - # Set the MacOS dock icon - app.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) - - if platform.system() == "Windows": - # For pure python, tell windows that we're not python, - # so we can have our own taskbar icon - import ctypes - - myappid = "comictagger" # arbitrary string - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # type: ignore[attr-defined] - # force close of console window - swp_hidewindow = 0x0080 - console_wnd = ctypes.windll.kernel32.GetConsoleWindow() # type: ignore[attr-defined] - if console_wnd != 0: - ctypes.windll.user32.SetWindowPos(console_wnd, None, 0, 0, 0, 0, swp_hidewindow) # type: ignore[attr-defined] - - if platform.system() != "Linux": - img = QtGui.QPixmap(str(graphics_path / "tags.png")) - - splash = QtWidgets.QSplashScreen(img) - splash.show() - splash.raise_() - QtWidgets.QApplication.processEvents() - - try: - tagger_window = TaggerWindow(opts.files, settings, talker_api, opts=opts) - tagger_window.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) - tagger_window.show() - - # Catch open file events (macOS) - app.openFileRequest.connect(tagger_window.open_file_event) - - if platform.system() != "Linux": - splash.finish(tagger_window) - - sys.exit(app.exec()) - except Exception: - logger.exception("GUI mode failed") - QtWidgets.QMessageBox.critical( - QtWidgets.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc() + # manage the CV API key + # None comparison is used so that the empty string can unset the value + if self.options["comicvine"]["cv_api_key"] is not None or self.options["comicvine"]["cv_url"] is not None: + self.options["comicvine"]["cv_api_key"] = ( + self.options["comicvine"]["cv_api_key"] + if self.options["comicvine"]["cv_api_key"] is not None + else self.options["comicvine"]["cv_api_key"] ) + self.options["comicvine"]["cv_url"] = ( + self.options["comicvine"]["cv_url"] + if self.options["comicvine"]["cv_url"] is not None + else self.options["comicvine"]["cv_url"] + ) + self.manager.save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") + logger.debug(pprint.pformat(self.options)) + if self.options["commands"]["only_set_cv_key"]: + print("Key set") # noqa: T201 + return + + signal.signal(signal.SIGINT, signal.SIG_DFL) + + logger.info( + "ComicTagger Version: %s running on: %s PyInstaller: %s", + version, + platform.system(), + "Yes" if getattr(sys, "frozen", None) else "No", + ) + + logger.debug("Installed Packages") + for pkg in sorted(importlib_metadata.distributions(), key=lambda x: x.name): + logger.debug("%s\t%s", pkg.metadata["Name"], pkg.metadata["Version"]) + + utils.load_publishers() + update_publishers(self.options) + + if not qt_available and not self.options["runtime"]["no_gui"]: + self.options["runtime"]["no_gui"] = True + logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.") + + gui_exception = None + try: + talker_api = ct_api.get_comic_talker("comicvine")( # type: ignore[call-arg] + version=version, + cache_folder=self.options["runtime"]["config"].user_cache_dir, + series_match_thresh=self.options["comicvine"]["series_match_search_thresh"], + remove_html_tables=self.options["comicvine"]["remove_html_tables"], + use_series_start_as_volume=self.options["comicvine"]["use_series_start_as_volume"], + wait_on_ratelimit=self.options["comicvine"]["wait_and_retry_on_rate_limit"], + api_url=self.options["comicvine"]["cv_url"], + api_key=self.options["comicvine"]["cv_api_key"], + ) + except TalkerError as e: + logger.exception("Unable to load talker") + gui_exception = e + if self.options["runtime"]["no_gui"]: + raise SystemExit(1) + + if self.options["runtime"]["no_gui"]: + try: + cli.cli_mode(self.options, talker_api) + except Exception: + logger.exception("CLI mode failed") + else: + + gui.open_tagger_window(talker_api, self.options, gui_exception) diff --git a/comictaggerlib/matchselectionwindow.py b/comictaggerlib/matchselectionwindow.py index 330318c..588fd2c 100644 --- a/comictaggerlib/matchselectionwindow.py +++ b/comictaggerlib/matchselectionwindow.py @@ -21,6 +21,7 @@ import os from PyQt5 import QtCore, QtWidgets, uic from comicapi.comicarchive import ComicArchive +from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.resulttypes import IssueResult from comictaggerlib.ui import ui_path @@ -38,18 +39,24 @@ class MatchSelectionWindow(QtWidgets.QDialog): parent: QtWidgets.QWidget, matches: list[IssueResult], comic_archive: ComicArchive, + options: settings.OptionValues, talker_api: ComicTalker, ) -> None: super().__init__(parent) uic.loadUi(ui_path / "matchselectionwindow.ui", self) - self.altCoverWidget = CoverImageWidget(self.altCoverContainer, talker_api, CoverImageWidget.AltCoverMode) + self.altCoverWidget = CoverImageWidget( + self.altCoverContainer, + CoverImageWidget.AltCoverMode, + options["runtime"]["config"].user_cache_dir, + talker_api, + ) gridlayout = QtWidgets.QGridLayout(self.altCoverContainer) gridlayout.addWidget(self.altCoverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) - self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, talker_api, CoverImageWidget.ArchiveMode) + self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, CoverImageWidget.ArchiveMode, None, None) gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer) gridlayout.addWidget(self.archiveCoverWidget) gridlayout.setContentsMargins(0, 0, 0, 0) diff --git a/comictaggerlib/options.py b/comictaggerlib/options.py deleted file mode 100644 index be9aecf..0000000 --- a/comictaggerlib/options.py +++ /dev/null @@ -1,418 +0,0 @@ -"""CLI options class for ComicTagger app""" -# -# Copyright 2012-2014 Anthony Beville -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from __future__ import annotations - -import argparse -import logging -import os -import platform -import sys - -from comicapi import utils -from comicapi.comicarchive import MetaDataStyle -from comicapi.genericmetadata import GenericMetadata -from comictaggerlib import ctversion - -logger = logging.getLogger(__name__) - - -def define_args() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="""A utility for reading and writing metadata to comic archives. - - If no options are given, %(prog)s will run in windowed mode.""", - epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki", - formatter_class=argparse.RawTextHelpFormatter, - ) - parser.add_argument( - "--version", - action="store_true", - help="Display version.", - ) - commands = parser.add_mutually_exclusive_group() - commands.add_argument( - "-p", - "--print", - action="store_true", - help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""", - ) - commands.add_argument( - "-d", - "--delete", - action="store_true", - help="Deletes the tag block of specified type (via -t).\n", - ) - commands.add_argument( - "-c", - "--copy", - type=metadata_type, - metavar="{CR,CBL,COMET}", - help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n", - ) - commands.add_argument( - "-s", - "--save", - action="store_true", - help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n", - ) - commands.add_argument( - "-r", - "--rename", - action="store_true", - help="Rename the file based on specified tag style.", - ) - commands.add_argument( - "-e", - "--export-to-zip", - action="store_true", - help="Export RAR archive to Zip format.", - ) - # TODO: update for new api - commands.add_argument( - "--only-set-cv-key", - action="store_true", - help="Only set the Comic Vine API key and quit.\n\n", - ) - parser.add_argument( - "-1", - "--assume-issue-one", - action="store_true", - help="""Assume issue number is 1 if not found (relevant for -s).\n\n""", - ) - parser.add_argument( - "--abort-on-conflict", - action="store_true", - help="""Don't export to zip if intended new filename\nexists (otherwise, creates a new unique filename).\n\n""", - ) - parser.add_argument( - "-a", - "--auto-imprint", - action="store_true", - help="""Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n""", - ) - parser.add_argument( - "--config", - dest="config_path", - help="""Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n""", - ) - # TODO: update for new api - parser.add_argument( - "--cv-api-key", - help="Use the given Comic Vine API Key (persisted in settings).", - ) - # TODO: update for new api - parser.add_argument( - "--cv-url", - help="Use the given Comic Vine URL (persisted in settings).", - ) - parser.add_argument( - "--delete-rar", - action="store_true", - dest="delete_after_zip_export", - help="""Delete original RAR archive after successful\nexport to Zip.""", - ) - parser.add_argument( - "-f", - "--parse-filename", - "--parsefilename", - action="store_true", - help="""Parse the filename to get some info,\nspecifically series name, issue number,\nvolume, and publication year.\n\n""", - ) - parser.add_argument( - "--id", - dest="issue_id", - type=int, - help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""", - ) - parser.add_argument( - "-t", - "--type", - metavar="{CR,CBL,COMET}", - default=[], - type=metadata_type, - help="""Specify TYPE as either CR, CBL or COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\nUse commas for multiple types.\nFor searching the metadata will use the first listed:\neg '-t cbl,cr' with no CBL tags, CR will be used if they exist\n\n""", - ) - parser.add_argument( - "-o", - "--online", - action="store_true", - help="""Search online and attempt to identify file\nusing existing metadata and images in archive.\nMay be used in conjunction with -f and -m.\n\n""", - ) - parser.add_argument( - "-m", - "--metadata", - default=GenericMetadata(), - type=parse_metadata_from_string, - help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""", - ) - parser.add_argument( - "-i", - "--interactive", - action="store_true", - help="""Interactively query the user when there are\nmultiple matches for an online search.\n\n""", - ) - parser.add_argument( - "--no-overwrite", - "--nooverwrite", - action="store_true", - help="""Don't modify tag block if it already exists (relevant for -s or -c).""", - ) - parser.add_argument( - "--noabort", - dest="abort_on_low_confidence", - action="store_false", - help="""Don't abort save operation when online match\nis of low confidence.\n\n""", - ) - parser.add_argument( - "--nosummary", - dest="show_save_summary", - action="store_false", - help="Suppress the default summary after a save operation.\n\n", - ) - parser.add_argument( - "--overwrite", - action="store_true", - help="""Overwrite all existing metadata.\nMay be used in conjunction with -o, -f and -m.\n\n""", - ) - parser.add_argument( - "--raw", action="store_true", help="""With -p, will print out the raw tag block(s)\nfrom the file.\n""" - ) - parser.add_argument( - "-R", - "--recursive", - action="store_true", - help="Recursively include files in sub-folders.", - ) - parser.add_argument( - "-S", - "--script", - help="""Run an "add-on" python script that uses the\nComicTagger library for custom processing.\nScript arguments can follow the script name.\n\n""", - ) - parser.add_argument( - "--split-words", - action="store_true", - help="""Splits words before parsing the filename.\ne.g. 'judgedredd' to 'judge dredd'\n\n""", - ) - parser.add_argument( - "--terse", - action="store_true", - help="Don't say much (for print mode).", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Be noisy when doing what it does.", - ) - # TODO: update for new api - parser.add_argument( - "-w", - "--wait-on-cv-rate-limit", - action="store_true", - help="""When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n""", - ) - parser.add_argument( - "-n", "--dryrun", action="store_true", help="Don't actually modify file (only relevant for -d, -s, or -r).\n\n" - ) - parser.add_argument( - "--darkmode", - action="store_true", - help="Windows only. Force a dark pallet", - ) - parser.add_argument( - "-g", - "--glob", - action="store_true", - help="Windows only. Enable globbing", - ) - parser.add_argument("files", nargs="*") - return parser - - -def metadata_type(types: str) -> list[int]: - result = [] - types = types.casefold() - for typ in types.split(","): - typ = typ.strip() - if typ not in MetaDataStyle.short_name: - choices = ", ".join(MetaDataStyle.short_name) - raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})") - result.append(MetaDataStyle.short_name.index(typ)) - return result - - -def parse_metadata_from_string(mdstr: str) -> GenericMetadata: - """The metadata string is a comma separated list of name-value pairs - The names match the attributes of the internal metadata struct (for now) - The caret is the special "escape character", since it's not common in - natural language text - - example = "series=Kickers^, Inc. ,issue=1, year=1986" - """ - - escaped_comma = "^," - escaped_equals = "^=" - replacement_token = "<_~_>" - - md = GenericMetadata() - - # First, replace escaped commas with with a unique token (to be changed back later) - mdstr = mdstr.replace(escaped_comma, replacement_token) - tmp_list = mdstr.split(",") - md_list = [] - for item in tmp_list: - item = item.replace(replacement_token, ",") - md_list.append(item) - - # Now build a nice dict from the list - md_dict = {} - for item in md_list: - # Make sure to fix any escaped equal signs - i = item.replace(escaped_equals, replacement_token) - key, value = i.split("=") - value = value.replace(replacement_token, "=").strip() - key = key.strip() - if key.casefold() == "credit": - cred_attribs = value.split(":") - role = cred_attribs[0] - person = cred_attribs[1] if len(cred_attribs) > 1 else "" - primary = len(cred_attribs) > 2 - md.add_credit(person.strip(), role.strip(), primary) - else: - md_dict[key] = value - - # Map the dict to the metadata object - for key, value in md_dict.items(): - if not hasattr(md, key): - raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name") - else: - md.is_empty = False - setattr(md, key, value) - return md - - -def launch_script(scriptfile: str, args: list[str]) -> None: - # we were given a script. special case for the args: - # 1. ignore everything before the -S, - # 2. pass all the ones that follow (including script name) to the script - if not os.path.exists(scriptfile): - logger.error("Can't find %s", scriptfile) - else: - # I *think* this makes sense: - # assume the base name of the file is the module name - # add the folder of the given file to the python path import module - dirname = os.path.dirname(scriptfile) - module_name = os.path.splitext(os.path.basename(scriptfile))[0] - sys.path = [dirname] + sys.path - try: - script = __import__(module_name) - - # Determine if the entry point exists before trying to run it - if "main" in dir(script): - script.main(args) - else: - logger.error("Can't find entry point 'main()' in module '%s'", module_name) - except Exception: - logger.exception("Script: %s raised an unhandled exception: ", module_name) - - sys.exit(0) - - -def parse_cmd_line() -> argparse.Namespace: - - if platform.system() == "Darwin" and getattr(sys, "frozen", False): - # remove the PSN (process serial number) argument from OS/X - input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a] - else: - input_args = sys.argv[1:] - - script_args = [] - - # first check if we're launching a script and split off script args - for n, _ in enumerate(input_args): - if input_args[n] == "--": - break - - if input_args[n] in ["-S", "--script"] and n + 1 < len(input_args): - # insert a "--" which will cause getopt to ignore the remaining args - # so they will be passed to the script - script_args = input_args[n + 2 :] - input_args = input_args[: n + 2] - break - - parser = define_args() - opts = parser.parse_args(input_args) - - if opts.config_path: - opts.config_path = os.path.abspath(opts.config_path) - if opts.version: - 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", - ) - - opts.no_gui = any( - [ - opts.print, - opts.delete, - opts.save, - opts.copy, - opts.rename, - opts.export_to_zip, - opts.only_set_cv_key, # TODO: update for new api - ] - ) - - if opts.script is not None: - launch_script(opts.script, script_args) - - if platform.system() == "Windows" and opts.glob: - # no globbing on windows shell, so do it for them - import glob - - globs = opts.files - opts.files = [] - for item in globs: - opts.files.extend(glob.glob(item)) - - # TODO: update for new api - if opts.only_set_cv_key and opts.cv_api_key is None and opts.cv_url is None: - parser.exit(message="Key not given!\n", status=1) - - # TODO: update for new api - if not opts.only_set_cv_key and opts.no_gui and not opts.files: - parser.exit(message="Command requires at least one filename!\n", status=1) - - if opts.delete and not opts.type: - parser.exit(message="Please specify the type to delete with -t\n", status=1) - - if opts.save and not opts.type: - parser.exit(message="Please specify the type to save with -t\n", status=1) - - if opts.copy: - if not opts.type: - parser.exit(message="Please specify the type to copy to with -t\n", status=1) - if len(opts.copy) > 1: - parser.exit(message="Please specify only one type to copy to with -c\n", status=1) - opts.copy = opts.copy[0] - - if opts.recursive: - opts.file_list = utils.get_recursive_filelist(opts.files) - else: - opts.file_list = opts.files - - return opts diff --git a/comictaggerlib/pagebrowser.py b/comictaggerlib/pagebrowser.py index f2e2387..79e7968 100644 --- a/comictaggerlib/pagebrowser.py +++ b/comictaggerlib/pagebrowser.py @@ -36,7 +36,7 @@ class PageBrowserWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "pagebrowser.ui", self) - self.pageWidget = CoverImageWidget(self.pageContainer, talker_api, CoverImageWidget.ArchiveMode) + self.pageWidget = CoverImageWidget(self.pageContainer, CoverImageWidget.ArchiveMode, None, None) gridlayout = QtWidgets.QGridLayout(self.pageContainer) gridlayout.addWidget(self.pageWidget) gridlayout.setContentsMargins(0, 0, 0, 0) diff --git a/comictaggerlib/pagelisteditor.py b/comictaggerlib/pagelisteditor.py index 39b71a9..136cb39 100644 --- a/comictaggerlib/pagelisteditor.py +++ b/comictaggerlib/pagelisteditor.py @@ -73,7 +73,7 @@ class PageListEditor(QtWidgets.QWidget): uic.loadUi(ui_path / "pagelisteditor.ui", self) - self.pageWidget = CoverImageWidget(self.pageContainer, talker_api, CoverImageWidget.ArchiveMode) + self.pageWidget = CoverImageWidget(self.pageContainer, CoverImageWidget.ArchiveMode, None, None) gridlayout = QtWidgets.QGridLayout(self.pageContainer) gridlayout.addWidget(self.pageWidget) gridlayout.setContentsMargins(0, 0, 0, 0) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index b732579..f231618 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -22,8 +22,8 @@ from PyQt5 import QtCore, QtWidgets, uic from comicapi import utils from comicapi.comicarchive import ComicArchive, MetaDataStyle from comicapi.genericmetadata import GenericMetadata +from comictaggerlib import settings from comictaggerlib.filerenamer import FileRenamer, get_rename_dir -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.settingswindow import SettingsWindow from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import center_window_on_parent @@ -38,7 +38,7 @@ class RenameWindow(QtWidgets.QDialog): parent: QtWidgets.QWidget, comic_archive_list: list[ComicArchive], data_style: int, - settings: ComicTaggerSettings, + options: settings.OptionValues, talker_api: ComicTalker, ) -> None: super().__init__(parent) @@ -54,25 +54,25 @@ class RenameWindow(QtWidgets.QDialog): ) ) - self.settings = settings + self.options = options self.talker_api = talker_api self.comic_archive_list = comic_archive_list self.data_style = data_style self.rename_list: list[str] = [] self.btnSettings.clicked.connect(self.modify_settings) - platform = "universal" if self.settings.rename_strict else "auto" + platform = "universal" if self.options["filename"]["rename_strict"] else "auto" self.renamer = FileRenamer(None, platform=platform) self.do_preview() def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str: - self.renamer.set_template(self.settings.rename_template) - self.renamer.set_issue_zero_padding(self.settings.rename_issue_number_padding) - self.renamer.set_smart_cleanup(self.settings.rename_use_smart_string_cleanup) + self.renamer.set_template(self.options["filename"]["rename_template"]) + self.renamer.set_issue_zero_padding(self.options["filename"]["rename_issue_number_padding"]) + self.renamer.set_smart_cleanup(self.options["filename"]["rename_use_smart_string_cleanup"]) new_ext = ca.path.suffix # default - if self.settings.rename_extension_based_on_archive: + if self.options["filename"]["rename_set_extension_based_on_archive"]: if ca.is_sevenzip(): new_ext = ".cb7" elif ca.is_zip(): @@ -84,13 +84,13 @@ class RenameWindow(QtWidgets.QDialog): md = ca.read_metadata(self.data_style) if md.is_empty: md = ca.metadata_from_filename( - self.settings.complicated_parser, - self.settings.remove_c2c, - self.settings.remove_fcbd, - self.settings.remove_publisher, + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], ) self.renamer.set_metadata(md) - self.renamer.move = self.settings.rename_move_dir + self.renamer.move = self.options["filename"]["rename_move_to_dir"] return new_ext def do_preview(self) -> None: @@ -103,7 +103,7 @@ class RenameWindow(QtWidgets.QDialog): try: new_name = self.renamer.determine_name(new_ext) except ValueError as e: - logger.exception("Invalid format string: %s", self.settings.rename_template) + logger.exception("Invalid format string: %s", self.options["filename"]["rename_template"]) QtWidgets.QMessageBox.critical( self, "Invalid format string!", @@ -117,7 +117,9 @@ class RenameWindow(QtWidgets.QDialog): return except Exception as e: logger.exception( - "Formatter failure: %s metadata: %s", self.settings.rename_template, self.renamer.metadata + "Formatter failure: %s metadata: %s", + self.options["filename"]["rename_template"], + self.renamer.metadata, ) QtWidgets.QMessageBox.critical( self, @@ -164,7 +166,7 @@ class RenameWindow(QtWidgets.QDialog): self.twList.setSortingEnabled(True) def modify_settings(self) -> None: - settingswin = SettingsWindow(self, self.settings, self.talker_api) + settingswin = SettingsWindow(self, self.options, self.talker_api) settingswin.setModal(True) settingswin.show_rename_tab() settingswin.exec() @@ -192,7 +194,10 @@ class RenameWindow(QtWidgets.QDialog): center_window_on_parent(prog_dialog) QtCore.QCoreApplication.processEvents() - folder = get_rename_dir(comic[0], self.settings.rename_dir if self.settings.rename_move_dir else None) + folder = get_rename_dir( + comic[0], + self.options["filename"]["rename_dir"] if self.options["filename"]["rename_move_to_dir"] else None, + ) full_path = folder / comic[1] diff --git a/comictaggerlib/settings.py b/comictaggerlib/settings.py deleted file mode 100644 index ac17af0..0000000 --- a/comictaggerlib/settings.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Settings class for ComicTagger app""" -# -# Copyright 2012-2014 Anthony Beville -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from __future__ import annotations - -import configparser -import logging -import os -import pathlib -import platform -import uuid -from collections.abc import Iterator -from typing import TextIO, no_type_check - -from comicapi import utils - -logger = logging.getLogger(__name__) - - -class ComicTaggerSettings: - folder: pathlib.Path | str = "" - - @staticmethod - def get_settings_folder() -> pathlib.Path: - if not ComicTaggerSettings.folder: - if platform.system() == "Windows": - ComicTaggerSettings.folder = pathlib.Path(os.environ["APPDATA"]) / "ComicTagger" - else: - ComicTaggerSettings.folder = pathlib.Path(os.path.expanduser("~")) / ".ComicTagger" - return pathlib.Path(ComicTaggerSettings.folder) - - def __init__(self, folder: str | pathlib.Path | None) -> None: - # General Settings - self.rar_exe_path = "" - self.allow_cbi_in_rar = True - self.check_for_new_version = False - self.send_usage_stats = False - - # automatic settings - self.install_id = uuid.uuid4().hex - self.last_selected_save_data_style = 0 - self.last_selected_load_data_style = 0 - self.last_opened_folder = "" - self.last_main_window_width = 0 - self.last_main_window_height = 0 - self.last_main_window_x = 0 - self.last_main_window_y = 0 - self.last_form_side_width = -1 - self.last_list_side_width = -1 - self.last_filelist_sorted_column = -1 - self.last_filelist_sorted_order = 0 - - # identifier settings - self.id_series_match_search_thresh = 90 - self.id_series_match_identify_thresh = 91 - self.id_publisher_filter = "Panini Comics, Abril, Planeta DeAgostini, Editorial Televisa, Dino Comics" - - # Show/ask dialog flags - self.ask_about_cbi_in_rar = True - self.show_disclaimer = True - self.settings_warning = 0 - self.dont_notify_about_this_version = "" - self.ask_about_usage_stats = True - - # filename parsing settings - self.complicated_parser = False - self.remove_c2c = False - self.remove_fcbd = False - self.remove_publisher = False - - # Comic Vine settings - self.use_series_start_as_volume = False - self.clear_form_before_populating_from_cv = False - self.remove_html_tables = False - self.cv_api_key = "" - self.cv_url = "" - self.auto_imprint = False - - self.sort_series_by_year = True - self.exact_series_matches_first = True - self.always_use_publisher_filter = False - - # CBL Transform settings - - self.assume_lone_credit_is_primary = False - self.copy_characters_to_tags = False - self.copy_teams_to_tags = False - self.copy_locations_to_tags = False - self.copy_storyarcs_to_tags = False - self.copy_notes_to_comments = False - self.copy_weblink_to_comments = False - self.apply_cbl_transform_on_cv_import = False - self.apply_cbl_transform_on_bulk_operation = False - - # Rename settings - self.rename_template = "{series} #{issue} ({year})" - self.rename_issue_number_padding = 3 - self.rename_use_smart_string_cleanup = True - self.rename_extension_based_on_archive = True - self.rename_dir = "" - self.rename_move_dir = False - self.rename_strict = False - - # Auto-tag stickies - self.save_on_low_confidence = False - self.dont_use_year_when_identifying = False - self.assume_1_if_no_issue_num = False - self.ignore_leading_numbers_in_filename = False - self.remove_archive_after_successful_match = False - self.wait_and_retry_on_rate_limit = False - - self.config = configparser.RawConfigParser() - if folder: - ComicTaggerSettings.folder = pathlib.Path(folder) - else: - ComicTaggerSettings.folder = ComicTaggerSettings.get_settings_folder() - - if not os.path.exists(ComicTaggerSettings.folder): - os.makedirs(ComicTaggerSettings.folder) - - self.settings_file = os.path.join(ComicTaggerSettings.folder, "settings") - - # if config file doesn't exist, write one out - if not os.path.exists(self.settings_file): - self.save() - else: - self.load() - - # take a crack at finding rar exe, if not set already - if self.rar_exe_path == "": - if platform.system() == "Windows": - # look in some likely places for Windows machines - if os.path.exists(r"C:\Program Files\WinRAR\Rar.exe"): - self.rar_exe_path = r"C:\Program Files\WinRAR\Rar.exe" - elif os.path.exists(r"C:\Program Files (x86)\WinRAR\Rar.exe"): - self.rar_exe_path = r"C:\Program Files (x86)\WinRAR\Rar.exe" - else: - if os.path.exists("/opt/homebrew/bin"): - utils.add_to_path("/opt/homebrew/bin") - # see if it's in the path of unix user - rarpath = utils.which("rar") - if rarpath is not None: - self.rar_exe_path = "rar" - if self.rar_exe_path != "": - self.save() - if self.rar_exe_path != "": - # make sure rar program is now in the path for the rar class - utils.add_to_path(os.path.dirname(self.rar_exe_path)) - - def reset(self) -> None: - os.unlink(self.settings_file) - self.__init__(ComicTaggerSettings.folder) # type: ignore[misc] - - def load(self) -> None: - def readline_generator(f: TextIO) -> Iterator[str]: - line = f.readline() - while line: - yield line - line = f.readline() - - with open(self.settings_file, encoding="utf-8") as f: - self.config.read_file(readline_generator(f)) - - self.rar_exe_path = self.config.get("settings", "rar_exe_path") - if self.config.has_option("settings", "check_for_new_version"): - self.check_for_new_version = self.config.getboolean("settings", "check_for_new_version") - if self.config.has_option("settings", "send_usage_stats"): - self.send_usage_stats = self.config.getboolean("settings", "send_usage_stats") - - if self.config.has_option("auto", "install_id"): - self.install_id = self.config.get("auto", "install_id") - if self.config.has_option("auto", "last_selected_load_data_style"): - self.last_selected_load_data_style = self.config.getint("auto", "last_selected_load_data_style") - if self.config.has_option("auto", "last_selected_save_data_style"): - self.last_selected_save_data_style = self.config.getint("auto", "last_selected_save_data_style") - if self.config.has_option("auto", "last_opened_folder"): - self.last_opened_folder = self.config.get("auto", "last_opened_folder") - if self.config.has_option("auto", "last_main_window_width"): - self.last_main_window_width = self.config.getint("auto", "last_main_window_width") - if self.config.has_option("auto", "last_main_window_height"): - self.last_main_window_height = self.config.getint("auto", "last_main_window_height") - if self.config.has_option("auto", "last_main_window_x"): - self.last_main_window_x = self.config.getint("auto", "last_main_window_x") - if self.config.has_option("auto", "last_main_window_y"): - self.last_main_window_y = self.config.getint("auto", "last_main_window_y") - if self.config.has_option("auto", "last_form_side_width"): - self.last_form_side_width = self.config.getint("auto", "last_form_side_width") - if self.config.has_option("auto", "last_list_side_width"): - self.last_list_side_width = self.config.getint("auto", "last_list_side_width") - if self.config.has_option("auto", "last_filelist_sorted_column"): - self.last_filelist_sorted_column = self.config.getint("auto", "last_filelist_sorted_column") - if self.config.has_option("auto", "last_filelist_sorted_order"): - self.last_filelist_sorted_order = self.config.getint("auto", "last_filelist_sorted_order") - - if self.config.has_option("identifier", "id_series_match_search_thresh"): - self.id_series_match_search_thresh = self.config.getint("identifier", "id_series_match_search_thresh") - if self.config.has_option("identifier", "id_series_match_identify_thresh"): - self.id_series_match_identify_thresh = self.config.getint("identifier", "id_series_match_identify_thresh") - if self.config.has_option("identifier", "id_publisher_filter"): - self.id_publisher_filter = self.config.get("identifier", "id_publisher_filter") - - if self.config.has_option("filenameparser", "complicated_parser"): - self.complicated_parser = self.config.getboolean("filenameparser", "complicated_parser") - if self.config.has_option("filenameparser", "remove_c2c"): - self.remove_c2c = self.config.getboolean("filenameparser", "remove_c2c") - if self.config.has_option("filenameparser", "remove_fcbd"): - self.remove_fcbd = self.config.getboolean("filenameparser", "remove_fcbd") - if self.config.has_option("filenameparser", "remove_publisher"): - self.remove_publisher = self.config.getboolean("filenameparser", "remove_publisher") - - if self.config.has_option("dialogflags", "ask_about_cbi_in_rar"): - self.ask_about_cbi_in_rar = self.config.getboolean("dialogflags", "ask_about_cbi_in_rar") - if self.config.has_option("dialogflags", "show_disclaimer"): - self.show_disclaimer = self.config.getboolean("dialogflags", "show_disclaimer") - if self.config.has_option("dialogflags", "settings_warning"): - self.settings_warning = self.config.getint("dialogflags", "settings_warning") - if self.config.has_option("dialogflags", "dont_notify_about_this_version"): - self.dont_notify_about_this_version = self.config.get("dialogflags", "dont_notify_about_this_version") - if self.config.has_option("dialogflags", "ask_about_usage_stats"): - self.ask_about_usage_stats = self.config.getboolean("dialogflags", "ask_about_usage_stats") - - if self.config.has_option("comicvine", "use_series_start_as_volume"): - self.use_series_start_as_volume = self.config.getboolean("comicvine", "use_series_start_as_volume") - if self.config.has_option("comicvine", "clear_form_before_populating_from_cv"): - self.clear_form_before_populating_from_cv = self.config.getboolean( - "comicvine", "clear_form_before_populating_from_cv" - ) - if self.config.has_option("comicvine", "remove_html_tables"): - self.remove_html_tables = self.config.getboolean("comicvine", "remove_html_tables") - - if self.config.has_option("comicvine", "sort_series_by_year"): - self.sort_series_by_year = self.config.getboolean("comicvine", "sort_series_by_year") - if self.config.has_option("comicvine", "exact_series_matches_first"): - self.exact_series_matches_first = self.config.getboolean("comicvine", "exact_series_matches_first") - if self.config.has_option("comicvine", "always_use_publisher_filter"): - self.always_use_publisher_filter = self.config.getboolean("comicvine", "always_use_publisher_filter") - - if self.config.has_option("comicvine", "cv_api_key"): - self.cv_api_key = self.config.get("comicvine", "cv_api_key") - - if self.config.has_option("comicvine", "cv_url"): - self.cv_url = self.config.get("comicvine", "cv_url") - - if self.config.has_option("cbl_transform", "assume_lone_credit_is_primary"): - self.assume_lone_credit_is_primary = self.config.getboolean( - "cbl_transform", "assume_lone_credit_is_primary" - ) - if self.config.has_option("cbl_transform", "copy_characters_to_tags"): - self.copy_characters_to_tags = self.config.getboolean("cbl_transform", "copy_characters_to_tags") - if self.config.has_option("cbl_transform", "copy_teams_to_tags"): - self.copy_teams_to_tags = self.config.getboolean("cbl_transform", "copy_teams_to_tags") - if self.config.has_option("cbl_transform", "copy_locations_to_tags"): - self.copy_locations_to_tags = self.config.getboolean("cbl_transform", "copy_locations_to_tags") - if self.config.has_option("cbl_transform", "copy_notes_to_comments"): - self.copy_notes_to_comments = self.config.getboolean("cbl_transform", "copy_notes_to_comments") - if self.config.has_option("cbl_transform", "copy_storyarcs_to_tags"): - self.copy_storyarcs_to_tags = self.config.getboolean("cbl_transform", "copy_storyarcs_to_tags") - if self.config.has_option("cbl_transform", "copy_weblink_to_comments"): - self.copy_weblink_to_comments = self.config.getboolean("cbl_transform", "copy_weblink_to_comments") - if self.config.has_option("cbl_transform", "apply_cbl_transform_on_cv_import"): - self.apply_cbl_transform_on_cv_import = self.config.getboolean( - "cbl_transform", "apply_cbl_transform_on_cv_import" - ) - if self.config.has_option("cbl_transform", "apply_cbl_transform_on_bulk_operation"): - self.apply_cbl_transform_on_bulk_operation = self.config.getboolean( - "cbl_transform", "apply_cbl_transform_on_bulk_operation" - ) - - if self.config.has_option("rename", "rename_template"): - self.rename_template = self.config.get("rename", "rename_template") - if self.config.has_option("rename", "rename_issue_number_padding"): - self.rename_issue_number_padding = self.config.getint("rename", "rename_issue_number_padding") - if self.config.has_option("rename", "rename_use_smart_string_cleanup"): - self.rename_use_smart_string_cleanup = self.config.getboolean("rename", "rename_use_smart_string_cleanup") - if self.config.has_option("rename", "rename_extension_based_on_archive"): - self.rename_extension_based_on_archive = self.config.getboolean( - "rename", "rename_extension_based_on_archive" - ) - if self.config.has_option("rename", "rename_dir"): - self.rename_dir = self.config.get("rename", "rename_dir") - if self.config.has_option("rename", "rename_move_dir"): - self.rename_move_dir = self.config.getboolean("rename", "rename_move_dir") - if self.config.has_option("rename", "rename_strict"): - self.rename_strict = self.config.getboolean("rename", "rename_strict") - - if self.config.has_option("autotag", "save_on_low_confidence"): - self.save_on_low_confidence = self.config.getboolean("autotag", "save_on_low_confidence") - if self.config.has_option("autotag", "dont_use_year_when_identifying"): - self.dont_use_year_when_identifying = self.config.getboolean("autotag", "dont_use_year_when_identifying") - if self.config.has_option("autotag", "assume_1_if_no_issue_num"): - self.assume_1_if_no_issue_num = self.config.getboolean("autotag", "assume_1_if_no_issue_num") - if self.config.has_option("autotag", "ignore_leading_numbers_in_filename"): - self.ignore_leading_numbers_in_filename = self.config.getboolean( - "autotag", "ignore_leading_numbers_in_filename" - ) - if self.config.has_option("autotag", "remove_archive_after_successful_match"): - self.remove_archive_after_successful_match = self.config.getboolean( - "autotag", "remove_archive_after_successful_match" - ) - if self.config.has_option("autotag", "wait_and_retry_on_rate_limit"): - self.wait_and_retry_on_rate_limit = self.config.getboolean("autotag", "wait_and_retry_on_rate_limit") - if self.config.has_option("autotag", "auto_imprint"): - self.auto_imprint = self.config.getboolean("autotag", "auto_imprint") - - @no_type_check - def save(self) -> None: - - if not self.config.has_section("settings"): - self.config.add_section("settings") - - self.config.set("settings", "check_for_new_version", self.check_for_new_version) - self.config.set("settings", "rar_exe_path", self.rar_exe_path) - self.config.set("settings", "send_usage_stats", self.send_usage_stats) - - if not self.config.has_section("auto"): - self.config.add_section("auto") - - self.config.set("auto", "install_id", self.install_id) - self.config.set("auto", "last_selected_load_data_style", self.last_selected_load_data_style) - self.config.set("auto", "last_selected_save_data_style", self.last_selected_save_data_style) - self.config.set("auto", "last_opened_folder", self.last_opened_folder) - self.config.set("auto", "last_main_window_width", self.last_main_window_width) - self.config.set("auto", "last_main_window_height", self.last_main_window_height) - self.config.set("auto", "last_main_window_x", self.last_main_window_x) - self.config.set("auto", "last_main_window_y", self.last_main_window_y) - self.config.set("auto", "last_form_side_width", self.last_form_side_width) - self.config.set("auto", "last_list_side_width", self.last_list_side_width) - self.config.set("auto", "last_filelist_sorted_column", self.last_filelist_sorted_column) - self.config.set("auto", "last_filelist_sorted_order", self.last_filelist_sorted_order) - - if not self.config.has_section("identifier"): - self.config.add_section("identifier") - - self.config.set("identifier", "id_series_match_search_thresh", self.id_series_match_search_thresh) - self.config.set("identifier", "id_series_match_identify_thresh", self.id_series_match_identify_thresh) - self.config.set("identifier", "id_publisher_filter", self.id_publisher_filter) - - if not self.config.has_section("dialogflags"): - self.config.add_section("dialogflags") - - self.config.set("dialogflags", "ask_about_cbi_in_rar", self.ask_about_cbi_in_rar) - self.config.set("dialogflags", "show_disclaimer", self.show_disclaimer) - self.config.set("dialogflags", "settings_warning", self.settings_warning) - self.config.set("dialogflags", "dont_notify_about_this_version", self.dont_notify_about_this_version) - self.config.set("dialogflags", "ask_about_usage_stats", self.ask_about_usage_stats) - - if not self.config.has_section("filenameparser"): - self.config.add_section("filenameparser") - - self.config.set("filenameparser", "complicated_parser", self.complicated_parser) - self.config.set("filenameparser", "remove_c2c", self.remove_c2c) - self.config.set("filenameparser", "remove_fcbd", self.remove_fcbd) - self.config.set("filenameparser", "remove_publisher", self.remove_publisher) - - if not self.config.has_section("comicvine"): - self.config.add_section("comicvine") - - self.config.set("comicvine", "use_series_start_as_volume", self.use_series_start_as_volume) - self.config.set("comicvine", "clear_form_before_populating_from_cv", self.clear_form_before_populating_from_cv) - self.config.set("comicvine", "remove_html_tables", self.remove_html_tables) - - self.config.set("comicvine", "sort_series_by_year", self.sort_series_by_year) - self.config.set("comicvine", "exact_series_matches_first", self.exact_series_matches_first) - self.config.set("comicvine", "always_use_publisher_filter", self.always_use_publisher_filter) - - self.config.set("comicvine", "cv_api_key", self.cv_api_key) - self.config.set("comicvine", "cv_url", self.cv_url) - - if not self.config.has_section("cbl_transform"): - self.config.add_section("cbl_transform") - - self.config.set("cbl_transform", "assume_lone_credit_is_primary", self.assume_lone_credit_is_primary) - self.config.set("cbl_transform", "copy_characters_to_tags", self.copy_characters_to_tags) - self.config.set("cbl_transform", "copy_teams_to_tags", self.copy_teams_to_tags) - self.config.set("cbl_transform", "copy_locations_to_tags", self.copy_locations_to_tags) - self.config.set("cbl_transform", "copy_storyarcs_to_tags", self.copy_storyarcs_to_tags) - self.config.set("cbl_transform", "copy_notes_to_comments", self.copy_notes_to_comments) - self.config.set("cbl_transform", "copy_weblink_to_comments", self.copy_weblink_to_comments) - self.config.set("cbl_transform", "apply_cbl_transform_on_cv_import", self.apply_cbl_transform_on_cv_import) - self.config.set( - "cbl_transform", - "apply_cbl_transform_on_bulk_operation", - self.apply_cbl_transform_on_bulk_operation, - ) - - if not self.config.has_section("rename"): - self.config.add_section("rename") - - self.config.set("rename", "rename_template", self.rename_template) - self.config.set("rename", "rename_issue_number_padding", self.rename_issue_number_padding) - self.config.set("rename", "rename_use_smart_string_cleanup", self.rename_use_smart_string_cleanup) - self.config.set("rename", "rename_extension_based_on_archive", self.rename_extension_based_on_archive) - self.config.set("rename", "rename_dir", self.rename_dir) - self.config.set("rename", "rename_move_dir", self.rename_move_dir) - self.config.set("rename", "rename_strict", self.rename_strict) - - if not self.config.has_section("autotag"): - self.config.add_section("autotag") - self.config.set("autotag", "save_on_low_confidence", self.save_on_low_confidence) - self.config.set("autotag", "dont_use_year_when_identifying", self.dont_use_year_when_identifying) - self.config.set("autotag", "assume_1_if_no_issue_num", self.assume_1_if_no_issue_num) - self.config.set("autotag", "ignore_leading_numbers_in_filename", self.ignore_leading_numbers_in_filename) - self.config.set("autotag", "remove_archive_after_successful_match", self.remove_archive_after_successful_match) - self.config.set("autotag", "wait_and_retry_on_rate_limit", self.wait_and_retry_on_rate_limit) - self.config.set("autotag", "auto_imprint", self.auto_imprint) - - with open(self.settings_file, "w", encoding="utf-8") as configfile: - self.config.write(configfile) diff --git a/comictaggerlib/settings/__init__.py b/comictaggerlib/settings/__init__.py new file mode 100644 index 0000000..5219e80 --- /dev/null +++ b/comictaggerlib/settings/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from comictaggerlib.settings.cmdline import initial_cmd_line_parser, register_commandline, validate_commandline_options +from comictaggerlib.settings.file import register_settings, validate_settings +from comictaggerlib.settings.manager import Manager +from comictaggerlib.settings.types import ComicTaggerPaths, OptionValues + +__all__ = [ + "initial_cmd_line_parser", + "register_commandline", + "register_settings", + "validate_commandline_options", + "validate_settings", + "Manager", + "ComicTaggerPaths", + "OptionValues", +] diff --git a/comictaggerlib/settings/cmdline.py b/comictaggerlib/settings/cmdline.py new file mode 100644 index 0000000..d84f403 --- /dev/null +++ b/comictaggerlib/settings/cmdline.py @@ -0,0 +1,343 @@ +"""CLI options class for ComicTagger app""" +# +# Copyright 2012-2014 Anthony Beville +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import argparse +import logging +import os +import platform +from typing import Any + +from comicapi import utils +from comicapi.genericmetadata import GenericMetadata +from comictaggerlib import ctversion +from comictaggerlib.settings.manager import Manager +from comictaggerlib.settings.types import ComicTaggerPaths, metadata_type, parse_metadata_from_string + +logger = logging.getLogger(__name__) + + +def initial_cmd_line_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(add_help=False) + # Ensure this stays up to date with register_options + parser.add_argument( + "--config", + help="Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n", + type=ComicTaggerPaths, + default=ComicTaggerPaths(), + ) + parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Be noisy when doing what it does.", + ) + return parser + + +def register_options(parser: Manager) -> None: + parser.add_setting( + "--config", + help="Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n", + type=ComicTaggerPaths, + default=ComicTaggerPaths(), + file=False, + ) + parser.add_setting( + "-v", + "--verbose", + action="count", + default=0, + help="Be noisy when doing what it does.", + file=False, + ) + parser.add_setting( + "--abort-on-conflict", + action="store_true", + help="""Don't export to zip if intended new filename\nexists (otherwise, creates a new unique filename).\n\n""", + file=False, + ) + parser.add_setting( + "--delete-original", + action="store_true", + dest="delete_after_zip_export", + help="""Delete original archive after successful\nexport to Zip. (only relevant for -e)""", + file=False, + ) + parser.add_setting( + "-f", + "--parse-filename", + "--parsefilename", + action="store_true", + help="""Parse the filename to get some info,\nspecifically series name, issue number,\nvolume, and publication year.\n\n""", + file=False, + ) + parser.add_setting( + "--id", + dest="issue_id", + type=int, + help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""", + file=False, + ) + parser.add_setting( + "-o", + "--online", + action="store_true", + help="""Search online and attempt to identify file\nusing existing metadata and images in archive.\nMay be used in conjunction with -f and -m.\n\n""", + file=False, + ) + parser.add_setting( + "-m", + "--metadata", + default=GenericMetadata(), + type=parse_metadata_from_string, + help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""", + file=False, + ) + parser.add_setting( + "-i", + "--interactive", + action="store_true", + help="""Interactively query the user when there are\nmultiple matches for an online search.\n\n""", + file=False, + ) + parser.add_setting( + "--noabort", + dest="abort_on_low_confidence", + action="store_false", + help="""Don't abort save operation when online match\nis of low confidence.\n\n""", + file=False, + ) + parser.add_setting( + "--nosummary", + dest="show_save_summary", + action="store_false", + help="Suppress the default summary after a save operation.\n\n", + file=False, + ) + parser.add_setting( + "--raw", + action="store_true", + help="""With -p, will print out the raw tag block(s)\nfrom the file.\n""", + file=False, + ) + parser.add_setting( + "-R", + "--recursive", + action="store_true", + help="Recursively include files in sub-folders.", + file=False, + ) + parser.add_setting( + "-S", + "--script", + help="""Run an "add-on" python script that uses the\nComicTagger library for custom processing.\nScript arguments can follow the script name.\n\n""", + file=False, + ) + parser.add_setting( + "--split-words", + action="store_true", + help="""Splits words before parsing the filename.\ne.g. 'judgedredd' to 'judge dredd'\n\n""", + file=False, + ) + parser.add_setting( + "-n", + "--dryrun", + action="store_true", + help="Don't actually modify file (only relevant for -d, -s, or -r).\n\n", + file=False, + ) + 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( + "--terse", + action="store_true", + help="Don't say much (for print mode).", + file=False, + ) + + parser.add_setting( + "-t", + "--type", + metavar="{CR,CBL,COMET}", + default=[], + type=metadata_type, + help="""Specify TYPE as either CR, CBL or COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\nUse commas for multiple types.\nFor searching the metadata will use the first listed:\neg '-t cbl,cr' with no CBL tags, CR will be used if they exist\n\n""", + file=False, + ) + parser.add_setting( + # "--no-overwrite", + # "--nooverwrite", + "--temporary", + dest="no_overwrite", + action="store_true", + help="""Don't modify tag block if it already exists (relevant for -s or -c).""", + file=False, + ) + parser.add_setting("files", nargs="*", file=False) + + +def register_commands(parser: Manager) -> None: + parser.add_setting( + "--version", + action="store_true", + help="Display version.", + file=False, + ) + + parser.add_setting( + "-p", + "--print", + action="store_true", + 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", + help="Deletes the tag block of specified type (via -t).\n", + file=False, + ) + parser.add_setting( + "-c", + "--copy", + type=metadata_type, + metavar="{CR,CBL,COMET}", + help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n", + file=False, + ) + parser.add_setting( + "-s", + "--save", + action="store_true", + 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", + help="Rename the file based on specified tag style.", + file=False, + ) + parser.add_setting( + "-e", + "--export-to-zip", + action="store_true", + help="Export RAR archive to Zip format.", + file=False, + ) + parser.add_setting( + "--only-set-cv-key", + action="store_true", + help="Only set the Comic Vine API key and quit.\n\n", + file=False, + ) + + +def register_commandline(parser: Manager) -> None: + parser.add_group("commands", register_commands, True) + parser.add_group("runtime", register_options) + + +def validate_commandline_options(options: dict[str, dict[str, Any]], parser: Manager) -> dict[str, dict[str, Any]]: + + if options["commands"]["version"]: + 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", + ) + + options["runtime"]["no_gui"] = any( + [ + options["commands"]["print"], + options["commands"]["delete"], + options["commands"]["save"], + options["commands"]["copy"], + options["commands"]["rename"], + options["commands"]["export_to_zip"], + options["commands"]["only_set_cv_key"], + ] + ) + + if platform.system() == "Windows" and options["runtime"]["glob"]: + # no globbing on windows shell, so do it for them + import glob + + globs = options["runtime"]["files"] + options["runtime"]["files"] = [] + for item in globs: + options["runtime"]["files"].extend(glob.glob(item)) + + if ( + options["commands"]["only_set_cv_key"] + and options["comicvine"]["cv_api_key"] is None + and options["comicvine"]["cv_url"] is None + ): + parser.exit(message="Key not given!\n", status=1) + + if not options["commands"]["only_set_cv_key"] and options["runtime"]["no_gui"] and not options["runtime"]["files"]: + parser.exit(message="Command requires at least one filename!\n", status=1) + + if options["commands"]["delete"] and not options["runtime"]["type"]: + parser.exit(message="Please specify the type to delete with -t\n", status=1) + + if options["commands"]["save"] and not options["runtime"]["type"]: + parser.exit(message="Please specify the type to save with -t\n", status=1) + + if options["commands"]["copy"]: + if not options["runtime"]["type"]: + parser.exit(message="Please specify the type to copy to with -t\n", status=1) + if len(options["commands"]["copy"]) > 1: + parser.exit(message="Please specify only one type to copy to with -c\n", status=1) + options["commands"]["copy"] = options["commands"]["copy"][0] + + if options["runtime"]["recursive"]: + options["runtime"]["file_list"] = utils.get_recursive_filelist(options["runtime"]["files"]) + else: + options["runtime"]["file_list"] = options["runtime"]["files"] + + # take a crack at finding rar exe, if not set already + if options["general"]["rar_exe_path"].strip() in ("", "rar"): + if platform.system() == "Windows": + # look in some likely places for Windows machines + if os.path.exists(r"C:\Program Files\WinRAR\Rar.exe"): + options["general"]["rar_exe_path"] = r"C:\Program Files\WinRAR\Rar.exe" + elif os.path.exists(r"C:\Program Files (x86)\WinRAR\Rar.exe"): + options["general"]["rar_exe_path"] = r"C:\Program Files (x86)\WinRAR\Rar.exe" + else: + if os.path.exists("/opt/homebrew/bin"): + utils.add_to_path("/opt/homebrew/bin") + # see if it's in the path of unix user + rarpath = utils.which("rar") + if rarpath is not None: + options["general"]["rar_exe_path"] = "rar" + + return options diff --git a/comictaggerlib/settings/file.py b/comictaggerlib/settings/file.py new file mode 100644 index 0000000..36931f5 --- /dev/null +++ b/comictaggerlib/settings/file.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import argparse +import uuid +from collections.abc import Sequence +from typing import Any, Callable + +from comictaggerlib.settings.manager import Manager + + +def general(parser: Manager) -> None: + # General Settings + parser.add_setting("--rar-exe-path", default="rar") + parser.add_setting("--allow-cbi-in-rar", default=True, action=argparse.BooleanOptionalAction) + parser.add_setting("check_for_new_version", default=False, cmdline=False) + parser.add_setting("send_usage_stats", default=False, cmdline=False) + + +def internal(parser: Manager) -> None: + # automatic settings + parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False) + parser.add_setting("last_selected_save_data_style", default=0, cmdline=False) + parser.add_setting("last_selected_load_data_style", default=0, cmdline=False) + parser.add_setting("last_opened_folder", default="", cmdline=False) + parser.add_setting("last_main_window_width", default=0, cmdline=False) + parser.add_setting("last_main_window_height", default=0, cmdline=False) + parser.add_setting("last_main_window_x", default=0, cmdline=False) + parser.add_setting("last_main_window_y", default=0, cmdline=False) + parser.add_setting("last_form_side_width", default=-1, cmdline=False) + parser.add_setting("last_list_side_width", default=-1, cmdline=False) + parser.add_setting("last_filelist_sorted_column", default=-1, cmdline=False) + parser.add_setting("last_filelist_sorted_order", default=0, cmdline=False) + + +def identifier(parser: Manager) -> None: + # identifier settings + parser.add_setting("--series-match-identify-thresh", default=91, type=int) + parser.add_setting( + "--publisher-filter", + default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"], + action=_AppendAction, + ) + + +def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]: + if items is None: + return [] + # The copy module is used only in the 'append' and 'append_const' + # actions, and it is needed only when the default value isn't a list. + # Delay its import for speeding up the common case. + if type(items) is list: + return items[:] + import copy + + return copy.copy(items) + + +class _AppendAction(argparse.Action): + def __init__( + self, + option_strings: list[str], + dest: str, + nargs: str | None = None, + const: Any = None, + default: Any = None, + type: Callable[[str], Any] | None = None, # noqa: A002 + choices: list[Any] | None = None, + required: bool = False, + help: str | None = None, # noqa: A002 + metavar: str | None = None, + ): + self.called = False + if nargs == 0: + raise ValueError( + "nargs for append actions must be != 0; if arg " + "strings are not supplying the value to append, " + "the append const action may be more appropriate" + ) + if const is not None and nargs != argparse.OPTIONAL: + raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL) + super().__init__( + option_strings=option_strings, + dest=dest, + nargs=nargs, + const=const, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar, + ) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + if values: + if not self.called: + setattr(namespace, self.dest, []) + items = getattr(namespace, self.dest, None) + items = _copy_items(items) + items.append(values) # type: ignore + setattr(namespace, self.dest, items) + + +def dialog(parser: Manager) -> None: + # Show/ask dialog flags + parser.add_setting("ask_about_cbi_in_rar", default=True, cmdline=False) + parser.add_setting("show_disclaimer", default=True, cmdline=False) + parser.add_setting("dont_notify_about_this_version", default="", cmdline=False) + parser.add_setting("ask_about_usage_stats", default=True, cmdline=False) + + +def filename(parser: Manager) -> None: + # filename parsing settings + parser.add_setting("--complicated-parser", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--remove-c2c", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--remove-fcbd", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--remove-publisher", default=False, action=argparse.BooleanOptionalAction) + + +def comicvine(parser: Manager) -> None: + # Comic Vine settings + parser.add_setting( + "--series-match-search-thresh", + default=90, + ) + parser.add_setting("--use-series-start-as-volume", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--overwrite", + default=True, + help="Overwrite all existing metadata.\nMay be used in conjunction with -o, -f and -m.\n\n", + dest="clear_metadata_on_import", + action=argparse.BooleanOptionalAction, + ) + parser.add_setting("--remove-html-tables", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--cv-api-key", + default="", + help="Use the given Comic Vine API Key (persisted in settings).", + ) + parser.add_setting( + "--cv-url", + default="", + help="Use the given Comic Vine URL (persisted in settings).", + ) + parser.add_setting( + "-a", + "--auto-imprint", + action=argparse.BooleanOptionalAction, + default=False, + help="""Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n""", + ) + + parser.add_setting("--sort-series-by-year", default=True, action=argparse.BooleanOptionalAction) + parser.add_setting("--exact-series-matches-first", default=True, action=argparse.BooleanOptionalAction) + parser.add_setting("--always-use-publisher-filter", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--clear-form-before-populating-from-cv", default=False, action=argparse.BooleanOptionalAction) + + +def cbl(parser: Manager) -> None: + # CBL Transform settings + parser.add_setting("--assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--copy-teams-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--copy-locations-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--apply-cbl-transform-on-cv-import", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--apply-cbl-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction) + + +def rename(parser: Manager) -> None: + # Rename settings + parser.add_setting("--template", default="{series} #{issue} ({year})") + parser.add_setting("--issue-number-padding", default=3, type=int) + parser.add_setting("--use-smart-string-cleanup", default=True, action=argparse.BooleanOptionalAction) + parser.add_setting("--set-extension-based-on-archive", default=True, action=argparse.BooleanOptionalAction) + parser.add_setting("--dir", default="") + parser.add_setting("--move-to-dir", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--strict", default=False, action=argparse.BooleanOptionalAction) + + +def autotag(parser: Manager) -> None: + # Auto-tag stickies + parser.add_setting("--save-on-low-confidence", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--dont-use-year-when-identifying", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "-1", + "--assume-issue-one", + dest="assume_1_if_no_issue_num", + action=argparse.BooleanOptionalAction, + help="""Assume issue number is 1 if not found (relevant for -s).\n\n""", + default=False, + ) + parser.add_setting("--ignore-leading-numbers-in-filename", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--remove-archive-after-successful-match", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "-w", + "--wait-on-cv-rate-limit", + dest="wait_and_retry_on_rate_limit", + action=argparse.BooleanOptionalAction, + default=True, + help="""When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n""", + ) + + +def validate_settings(options: dict[str, dict[str, Any]], parser: Manager) -> dict[str, dict[str, Any]]: + options["identifier"]["publisher_filter"] = [ + x.strip() for x in options["identifier"]["publisher_filter"] if x.strip() + ] + return options + + +def register_settings(parser: Manager) -> None: + parser.add_group("general", general, False) + parser.add_group("internal", internal, False) + parser.add_group("identifier", identifier, False) + parser.add_group("dialog", dialog, False) + parser.add_group("filename", filename, False) + parser.add_group("comicvine", comicvine, False) + parser.add_group("cbl", cbl, False) + parser.add_group("rename", rename, False) + parser.add_group("autotag", autotag, False) diff --git a/comictaggerlib/settings/manager.py b/comictaggerlib/settings/manager.py new file mode 100644 index 0000000..5dfb1e9 --- /dev/null +++ b/comictaggerlib/settings/manager.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import argparse +import json +import logging +import pathlib +from collections import defaultdict +from collections.abc import Sequence +from typing import Any, Callable, NoReturn, Union + +from comictaggerlib.settings.types import ComicTaggerPaths, OptionValues + +logger = logging.getLogger(__name__) + + +class Setting: + def __init__( + self, + # From argparse + *names: str, + action: type[argparse.Action] | None = None, + nargs: str | int | None = None, + const: str | None = None, + default: str | None = None, + type: Callable[..., Any] | None = None, # noqa: A002 + choices: Sequence[Any] | None = None, + required: bool | None = None, + help: str | None = None, # noqa: A002 + metavar: str | None = None, + dest: str | None = None, + # ComicTagger + cmdline: bool = True, + file: bool = True, + group: str = "", + exclusive: bool = False, + ): + if not names: + raise ValueError("names must be specified") + self.internal_name, dest, flag = self.get_dest(group, names, dest) + args: Sequence[str] = names + if not metavar and action not in ("store_true", "store_false", "count"): + metavar = dest.upper() + + if not flag: + args = [f"{group}_{names[0]}".lstrip("_"), *names[1:]] + + self.action = action + self.nargs = nargs + self.const = const + self.default = default + self.type = type + self.choices = choices + self.required = required + self.help = help + self.metavar = metavar + self.dest = dest + self.cmdline = cmdline + self.file = file + self.argparse_args = args + self.group = group + self.exclusive = exclusive + + self.argparse_kwargs = { + "action": action, + "nargs": nargs, + "const": const, + "default": default, + "type": type, + "choices": choices, + "required": required, + "help": help, + "metavar": metavar, + "dest": self.internal_name if flag else None, + } + + def __str__(self) -> str: + return f"Setting({self.argparse_args}, type={self.type}, file={self.file}, cmdline={self.cmdline}, kwargs={self.argparse_kwargs})" + + def __repr__(self) -> str: + return self.__str__() + + def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]: + dest_name = None + flag = False + + for n in names: + if n.startswith("--"): + flag = True + dest_name = n.lstrip("-").replace("-", "_") + break + if n.startswith("-"): + flag = True + + if dest_name is None: + dest_name = names[0] + if dest: + dest_name = dest + if dest_name is None: + raise Exception("Something failed, try again") + + internal_name = f"{prefix}_{dest_name}".lstrip("_") + return internal_name, dest_name, flag + + def filter_argparse_kwargs(self) -> dict[str, Any]: + return {k: v for k, v in self.argparse_kwargs.items() if v is not None} + + def to_argparse(self) -> tuple[Sequence[str], dict[str, Any]]: + return self.argparse_args, self.filter_argparse_kwargs() + + +ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] + + +class Manager: + """docstring for SettingManager""" + + def __init__(self, options: dict[str, dict[str, Setting]] | None = None): + self.argparser = argparse.ArgumentParser() + + self.options: dict[str, dict[str, Setting]] = defaultdict(lambda: dict()) + if options: + self.options = options + + self.current_group: ArgParser | None = None + self.current_group_name = "" + + def defaults(self) -> OptionValues: + return self.normalize_options({}, file=True, cmdline=True) + + def get_namespace_for_args(self, options: OptionValues) -> argparse.Namespace: + namespace = argparse.Namespace() + for group_name, group in self.options.items(): + for setting_name, setting in group.items(): + if not setting.cmdline: + if hasattr(namespace, setting.internal_name): + raise Exception(f"Duplicate internal name: {setting.internal_name}") + setattr( + namespace, setting.internal_name, options.get(group_name, {}).get(setting.dest, setting.default) + ) + return namespace + + def add_setting(self, *args: Any, **kwargs: Any) -> None: + exclusive = isinstance(self.current_group, argparse._MutuallyExclusiveGroup) + setting = Setting(*args, group=self.current_group_name, exclusive=exclusive, **kwargs) + self.options[self.current_group_name][setting.dest] = setting + + def create_argparser(self) -> None: + groups: dict[str, ArgParser] = {} + self.argparser = argparse.ArgumentParser( + description="""A utility for reading and writing metadata to comic archives. + + +If no options are given, %(prog)s will run in windowed mode.""", + epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki", + formatter_class=argparse.RawTextHelpFormatter, + ) + for group_name, group in self.options.items(): + for setting_name, setting in group.items(): + if setting.cmdline: + argparse_args, argparse_kwargs = setting.to_argparse() + current_group: ArgParser = self.argparser + if setting.group: + if setting.group not in groups: + if setting.exclusive: + groups[setting.group] = self.argparser.add_argument_group( + setting.group + ).add_mutually_exclusive_group() + else: + groups[setting.group] = self.argparser.add_argument_group(setting.group) + + # hardcoded exception for files + if not (setting.group == "runtime" and setting.nargs == "*"): + current_group = groups[setting.group] + current_group.add_argument(*argparse_args, **argparse_kwargs) + + def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None: + self.current_group_name = name + if exclusive_group: + self.current_group = self.argparser.add_mutually_exclusive_group() + add_settings(self) + self.current_group_name = "" + self.current_group = None + + def exit(self, *args: Any, **kwargs: Any) -> NoReturn: + self.argparser.exit(*args, **kwargs) + raise SystemExit(99) + + def save_file(self, options: OptionValues, filename: pathlib.Path) -> bool: + self.options = options["option_definitions"] + file_options = self.normalize_options(options, file=True) + del file_options["option_definitions"] + for group in list(file_options.keys()): + if not file_options[group]: + del file_options[group] + if not filename.exists(): + filename.touch() + + try: + json_str = json.dumps(file_options, indent=2) + with filename.open(mode="w") as file: + file.write(json_str) + except Exception: + logger.exception("Failed to save config file: %s", filename) + return False + return True + + def parse_file(self, filename: pathlib.Path) -> OptionValues: + options: OptionValues = {} + if filename.exists(): + try: + with filename.open() as file: + opts = json.load(file) + if isinstance(opts, dict): + options = opts + except Exception: + logger.exception("Failed to load config file: %s", filename) + else: + logger.info("No config file found") + + return self.normalize_options(options, file=True) + + def normalize_options( + self, raw_options: OptionValues | argparse.Namespace, file: bool = False, cmdline: bool = False + ) -> OptionValues: + options: OptionValues = {} + for group_name, group in self.options.items(): + group_options = {} + for setting_name, setting in group.items(): + if (setting.cmdline and cmdline) or (setting.file and file): + group_options[setting_name] = self.get_option(raw_options, setting, group_name) + options[group_name] = group_options + options["option_definitions"] = self.options + return options + + def get_option(self, options: OptionValues | argparse.Namespace, setting: Setting, group_name: str) -> Any: + if isinstance(options, dict): + return options.get(group_name, {}).get(setting.dest, setting.default) + return getattr(options, setting.internal_name) + + def parse_args(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> OptionValues: + """Must handle a namespace with both argparse values and file values""" + self.create_argparser() + ns = self.argparser.parse_args(args, namespace=namespace) + + return self.normalize_options(ns, cmdline=True, file=True) + + def parse_options(self, config_paths: ComicTaggerPaths, args: list[str] | None = None) -> OptionValues: + file_options = self.parse_file(config_paths.user_config_dir / "settings.json") + cli_options = self.parse_args(args, namespace=self.get_namespace_for_args(file_options)) + + # for group, group_options in cli_options.items(): + # if group in cli_options: + # file_options[group].update(group_options) + # else: + # file_options[group] = group_options + # Just in case something weird happens with the commandline options + file_options["runtime"]["config"] = config_paths + + # Normalize a final time for fun + return self.normalize_options(cli_options, file=True, cmdline=True) diff --git a/comictaggerlib/settings/types.py b/comictaggerlib/settings/types.py new file mode 100644 index 0000000..774fbdb --- /dev/null +++ b/comictaggerlib/settings/types.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import argparse +import pathlib +from typing import Any + +from appdirs import AppDirs + +from comicapi.comicarchive import MetaDataStyle +from comicapi.genericmetadata import GenericMetadata + +OptionValues = dict[str, dict[str, Any]] + + +class ComicTaggerPaths(AppDirs): + def __init__(self, config_path: pathlib.Path | str | None = None) -> None: + super().__init__("ComicTagger", None, None, False, False) + self.path: pathlib.Path | None = None + if config_path: + self.path = pathlib.Path(config_path).absolute() + + @property + def user_data_dir(self) -> pathlib.Path: + if self.path: + return self.path + return pathlib.Path(super().user_data_dir) + + @property + def user_config_dir(self) -> pathlib.Path: + if self.path: + return self.path + return pathlib.Path(super().user_config_dir) + + @property + def user_cache_dir(self) -> pathlib.Path: + if self.path: + path = self.path / "cache" + return path + return pathlib.Path(super().user_cache_dir) + + @property + def user_state_dir(self) -> pathlib.Path: + if self.path: + return self.path + return pathlib.Path(super().user_state_dir) + + @property + def user_log_dir(self) -> pathlib.Path: + if self.path: + path = self.path / "log" + return path + return pathlib.Path(super().user_log_dir) + + @property + def site_data_dir(self) -> pathlib.Path: + return pathlib.Path(super().site_data_dir) + + @property + def site_config_dir(self) -> pathlib.Path: + return pathlib.Path(super().site_config_dir) + + +def metadata_type(types: str) -> list[int]: + result = [] + types = types.casefold() + for typ in types.split(","): + typ = typ.strip() + if typ not in MetaDataStyle.short_name: + choices = ", ".join(MetaDataStyle.short_name) + raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})") + result.append(MetaDataStyle.short_name.index(typ)) + return result + + +def parse_metadata_from_string(mdstr: str) -> GenericMetadata: + """The metadata string is a comma separated list of name-value pairs + The names match the attributes of the internal metadata struct (for now) + The caret is the special "escape character", since it's not common in + natural language text + + example = "series=Kickers^, Inc. ,issue=1, year=1986" + """ + + escaped_comma = "^," + escaped_equals = "^=" + replacement_token = "<_~_>" + + md = GenericMetadata() + + # First, replace escaped commas with with a unique token (to be changed back later) + mdstr = mdstr.replace(escaped_comma, replacement_token) + tmp_list = mdstr.split(",") + md_list = [] + for item in tmp_list: + item = item.replace(replacement_token, ",") + md_list.append(item) + + # Now build a nice dict from the list + md_dict = {} + for item in md_list: + # Make sure to fix any escaped equal signs + i = item.replace(escaped_equals, replacement_token) + key, value = i.split("=") + value = value.replace(replacement_token, "=").strip() + key = key.strip() + if key.casefold() == "credit": + cred_attribs = value.split(":") + role = cred_attribs[0] + person = cred_attribs[1] if len(cred_attribs) > 1 else "" + primary = len(cred_attribs) > 2 + md.add_credit(person.strip(), role.strip(), primary) + else: + md_dict[key] = value + + # Map the dict to the metadata object + for key, value in md_dict.items(): + if not hasattr(md, key): + raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name") + else: + md.is_empty = False + setattr(md, key, value) + return md diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 6152e26..b786869 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -25,10 +25,10 @@ from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi import utils from comicapi.genericmetadata import md_test +from comictaggerlib import settings from comictaggerlib.ctversion import version from comictaggerlib.filerenamer import FileRenamer from comictaggerlib.imagefetcher import ImageFetcher -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.ui import ui_path from comictalker.comiccacher import ComicCacher from comictalker.talkerbase import ComicTalker @@ -129,7 +129,7 @@ Spider-Geddon #1 - New Players; Check In class SettingsWindow(QtWidgets.QDialog): - def __init__(self, parent: QtWidgets.QWidget, settings: ComicTaggerSettings, talker_api: ComicTalker) -> None: + def __init__(self, parent: QtWidgets.QWidget, options: settings.OptionValues, talker_api: ComicTalker) -> None: super().__init__(parent) uic.loadUi(ui_path / "settingswindow.ui", self) @@ -138,7 +138,7 @@ class SettingsWindow(QtWidgets.QDialog): QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint) ) - self.settings = settings + self.options = options self.talker_api = talker_api self.name = "Settings" @@ -227,53 +227,55 @@ class SettingsWindow(QtWidgets.QDialog): def settings_to_form(self) -> None: # Copy values from settings to form - self.leRarExePath.setText(self.settings.rar_exe_path) - self.sbNameMatchIdentifyThresh.setValue(self.settings.id_series_match_identify_thresh) - self.sbNameMatchSearchThresh.setValue(self.settings.id_series_match_search_thresh) - self.tePublisherFilter.setPlainText(self.settings.id_publisher_filter) + self.leRarExePath.setText(self.options["general"]["rar_exe_path"]) + self.sbNameMatchIdentifyThresh.setValue(self.options["identifier"]["series_match_identify_thresh"]) + self.sbNameMatchSearchThresh.setValue(self.options["comicvine"]["series_match_search_thresh"]) + self.tePublisherFilter.setPlainText("\n".join(self.options["identifier"]["publisher_filter"])) - self.cbxCheckForNewVersion.setChecked(self.settings.check_for_new_version) + self.cbxCheckForNewVersion.setChecked(self.options["general"]["check_for_new_version"]) - self.cbxComplicatedParser.setChecked(self.settings.complicated_parser) - self.cbxRemoveC2C.setChecked(self.settings.remove_c2c) - self.cbxRemoveFCBD.setChecked(self.settings.remove_fcbd) - self.cbxRemovePublisher.setChecked(self.settings.remove_publisher) + self.cbxComplicatedParser.setChecked(self.options["filename"]["complicated_parser"]) + self.cbxRemoveC2C.setChecked(self.options["filename"]["remove_c2c"]) + self.cbxRemoveFCBD.setChecked(self.options["filename"]["remove_fcbd"]) + self.cbxRemovePublisher.setChecked(self.options["filename"]["remove_publisher"]) self.switch_parser() - self.cbxUseSeriesStartAsVolume.setChecked(self.settings.use_series_start_as_volume) - self.cbxClearFormBeforePopulating.setChecked(self.settings.clear_form_before_populating_from_cv) - self.cbxRemoveHtmlTables.setChecked(self.settings.remove_html_tables) + self.cbxUseSeriesStartAsVolume.setChecked(self.options["comicvine"]["use_series_start_as_volume"]) + self.cbxClearFormBeforePopulating.setChecked(self.options["comicvine"]["clear_form_before_populating_from_cv"]) + self.cbxRemoveHtmlTables.setChecked(self.options["comicvine"]["remove_html_tables"]) - self.cbxUseFilter.setChecked(self.settings.always_use_publisher_filter) - self.cbxSortByYear.setChecked(self.settings.sort_series_by_year) - self.cbxExactMatches.setChecked(self.settings.exact_series_matches_first) + self.cbxUseFilter.setChecked(self.options["comicvine"]["always_use_publisher_filter"]) + self.cbxSortByYear.setChecked(self.options["comicvine"]["sort_series_by_year"]) + self.cbxExactMatches.setChecked(self.options["comicvine"]["exact_series_matches_first"]) - self.leKey.setText(self.settings.cv_api_key) - self.leURL.setText(self.settings.cv_url) + self.leKey.setText(self.options["comicvine"]["cv_api_key"]) + self.leURL.setText(self.options["comicvine"]["cv_url"]) - self.cbxAssumeLoneCreditIsPrimary.setChecked(self.settings.assume_lone_credit_is_primary) - self.cbxCopyCharactersToTags.setChecked(self.settings.copy_characters_to_tags) - self.cbxCopyTeamsToTags.setChecked(self.settings.copy_teams_to_tags) - self.cbxCopyLocationsToTags.setChecked(self.settings.copy_locations_to_tags) - self.cbxCopyStoryArcsToTags.setChecked(self.settings.copy_storyarcs_to_tags) - self.cbxCopyNotesToComments.setChecked(self.settings.copy_notes_to_comments) - self.cbxCopyWebLinkToComments.setChecked(self.settings.copy_weblink_to_comments) - self.cbxApplyCBLTransformOnCVIMport.setChecked(self.settings.apply_cbl_transform_on_cv_import) - self.cbxApplyCBLTransformOnBatchOperation.setChecked(self.settings.apply_cbl_transform_on_bulk_operation) + self.cbxAssumeLoneCreditIsPrimary.setChecked(self.options["cbl"]["assume_lone_credit_is_primary"]) + self.cbxCopyCharactersToTags.setChecked(self.options["cbl"]["copy_characters_to_tags"]) + self.cbxCopyTeamsToTags.setChecked(self.options["cbl"]["copy_teams_to_tags"]) + self.cbxCopyLocationsToTags.setChecked(self.options["cbl"]["copy_locations_to_tags"]) + self.cbxCopyStoryArcsToTags.setChecked(self.options["cbl"]["copy_storyarcs_to_tags"]) + self.cbxCopyNotesToComments.setChecked(self.options["cbl"]["copy_notes_to_comments"]) + self.cbxCopyWebLinkToComments.setChecked(self.options["cbl"]["copy_weblink_to_comments"]) + self.cbxApplyCBLTransformOnCVIMport.setChecked(self.options["cbl"]["apply_cbl_transform_on_cv_import"]) + self.cbxApplyCBLTransformOnBatchOperation.setChecked( + self.options["cbl"]["apply_cbl_transform_on_bulk_operation"] + ) - self.leRenameTemplate.setText(self.settings.rename_template) - self.leIssueNumPadding.setText(str(self.settings.rename_issue_number_padding)) - self.cbxSmartCleanup.setChecked(self.settings.rename_use_smart_string_cleanup) - self.cbxChangeExtension.setChecked(self.settings.rename_extension_based_on_archive) - self.cbxMoveFiles.setChecked(self.settings.rename_move_dir) - self.leDirectory.setText(self.settings.rename_dir) - self.cbxRenameStrict.setChecked(self.settings.rename_strict) + self.leRenameTemplate.setText(self.options["rename"]["template"]) + self.leIssueNumPadding.setText(str(self.options["rename"]["issue_number_padding"])) + self.cbxSmartCleanup.setChecked(self.options["rename"]["use_smart_string_cleanup"]) + self.cbxChangeExtension.setChecked(self.options["rename"]["set_extension_based_on_archive"]) + self.cbxMoveFiles.setChecked(self.options["rename"]["move_to_dir"]) + self.leDirectory.setText(self.options["rename"]["dir"]) + self.cbxRenameStrict.setChecked(self.options["rename"]["strict"]) def accept(self) -> None: self.rename_test() if self.rename_error is not None: if isinstance(self.rename_error, ValueError): - logger.exception("Invalid format string: %s", self.settings.rename_template) + logger.exception("Invalid format string: %s", self.options["rename"]["template"]) QtWidgets.QMessageBox.critical( self, "Invalid format string!", @@ -287,7 +289,7 @@ class SettingsWindow(QtWidgets.QDialog): return else: logger.exception( - "Formatter failure: %s metadata: %s", self.settings.rename_template, self.renamer.metadata + "Formatter failure: %s metadata: %s", self.options["rename"]["template"], self.renamer.metadata ) QtWidgets.QMessageBox.critical( self, @@ -300,69 +302,77 @@ class SettingsWindow(QtWidgets.QDialog): ) # Copy values from form to settings and save - self.settings.rar_exe_path = str(self.leRarExePath.text()) + self.options["general"]["rar_exe_path"] = str(self.leRarExePath.text()) # make sure rar program is now in the path for the rar class - if self.settings.rar_exe_path: - utils.add_to_path(os.path.dirname(self.settings.rar_exe_path)) + if self.options["general"]["rar_exe_path"]: + utils.add_to_path(os.path.dirname(self.options["general"]["rar_exe_path"])) if not str(self.leIssueNumPadding.text()).isdigit(): self.leIssueNumPadding.setText("0") - self.settings.check_for_new_version = self.cbxCheckForNewVersion.isChecked() + self.options["general"]["check_for_new_version"] = self.cbxCheckForNewVersion.isChecked() - self.settings.id_series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value() - self.settings.id_series_match_search_thresh = self.sbNameMatchSearchThresh.value() - self.settings.id_publisher_filter = str(self.tePublisherFilter.toPlainText()) + self.options["identifier"]["series_match_identify_thresh"] = self.sbNameMatchIdentifyThresh.value() + self.options["comicvine"]["series_match_search_thresh"] = self.sbNameMatchSearchThresh.value() + self.options["identifier"]["publisher_filter"] = [ + x.strip() for x in str(self.tePublisherFilter.toPlainText()).splitlines() if x.strip() + ] - self.settings.complicated_parser = self.cbxComplicatedParser.isChecked() - self.settings.remove_c2c = self.cbxRemoveC2C.isChecked() - self.settings.remove_fcbd = self.cbxRemoveFCBD.isChecked() - self.settings.remove_publisher = self.cbxRemovePublisher.isChecked() + self.options["filename"]["complicated_parser"] = self.cbxComplicatedParser.isChecked() + self.options["filename"]["remove_c2c"] = self.cbxRemoveC2C.isChecked() + self.options["filename"]["remove_fcbd"] = self.cbxRemoveFCBD.isChecked() + self.options["filename"]["remove_publisher"] = self.cbxRemovePublisher.isChecked() - self.settings.use_series_start_as_volume = self.cbxUseSeriesStartAsVolume.isChecked() - self.settings.clear_form_before_populating_from_cv = self.cbxClearFormBeforePopulating.isChecked() - self.settings.remove_html_tables = self.cbxRemoveHtmlTables.isChecked() + self.options["comicvine"]["use_series_start_as_volume"] = self.cbxUseSeriesStartAsVolume.isChecked() + self.options["comicvine"][ + "clear_form_before_populating_from_cv" + ] = self.cbxClearFormBeforePopulating.isChecked() + self.options["comicvine"]["remove_html_tables"] = self.cbxRemoveHtmlTables.isChecked() - self.settings.always_use_publisher_filter = self.cbxUseFilter.isChecked() - self.settings.sort_series_by_year = self.cbxSortByYear.isChecked() - self.settings.exact_series_matches_first = self.cbxExactMatches.isChecked() + self.options["comicvine"]["always_use_publisher_filter"] = self.cbxUseFilter.isChecked() + self.options["comicvine"]["sort_series_by_year"] = self.cbxSortByYear.isChecked() + self.options["comicvine"]["exact_series_matches_first"] = self.cbxExactMatches.isChecked() - # Ignore empty field if self.leKey.text().strip(): - self.settings.cv_api_key = self.leKey.text().strip() - self.talker_api.api_key = self.settings.cv_api_key + self.options["comicvine"]["cv_api_key"] = self.leKey.text().strip() + self.talker_api.api_key = self.options["comicvine"]["cv_api_key"] + if self.leURL.text().strip(): - self.settings.cv_url = self.leURL.text().strip() - self.talker_api.api_url = self.settings.cv_url - self.settings.assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked() - self.settings.copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked() - self.settings.copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked() - self.settings.copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked() - self.settings.copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked() - self.settings.copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked() - self.settings.copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked() - self.settings.apply_cbl_transform_on_cv_import = self.cbxApplyCBLTransformOnCVIMport.isChecked() - self.settings.apply_cbl_transform_on_bulk_operation = self.cbxApplyCBLTransformOnBatchOperation.isChecked() + self.options["comicvine"]["cv_url"] = self.leURL.text().strip() + self.talker_api.api_url = self.options["comicvine"]["cv_url"] - self.settings.rename_template = str(self.leRenameTemplate.text()) - self.settings.rename_issue_number_padding = int(self.leIssueNumPadding.text()) - self.settings.rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked() - self.settings.rename_extension_based_on_archive = self.cbxChangeExtension.isChecked() - self.settings.rename_move_dir = self.cbxMoveFiles.isChecked() - self.settings.rename_dir = self.leDirectory.text() + self.options["cbl"]["assume_lone_credit_is_primary"] = self.cbxAssumeLoneCreditIsPrimary.isChecked() + self.options["cbl"]["copy_characters_to_tags"] = self.cbxCopyCharactersToTags.isChecked() + self.options["cbl"]["copy_teams_to_tags"] = self.cbxCopyTeamsToTags.isChecked() + self.options["cbl"]["copy_locations_to_tags"] = self.cbxCopyLocationsToTags.isChecked() + self.options["cbl"]["copy_storyarcs_to_tags"] = self.cbxCopyStoryArcsToTags.isChecked() + self.options["cbl"]["copy_notes_to_comments"] = self.cbxCopyNotesToComments.isChecked() + self.options["cbl"]["copy_weblink_to_comments"] = self.cbxCopyWebLinkToComments.isChecked() + self.options["cbl"]["apply_cbl_transform_on_cv_import"] = self.cbxApplyCBLTransformOnCVIMport.isChecked() + self.options["cbl"][ + "apply_cbl_transform_on_bulk_operation" + ] = self.cbxApplyCBLTransformOnBatchOperation.isChecked() - self.settings.rename_strict = self.cbxRenameStrict.isChecked() + self.options["rename"]["template"] = str(self.leRenameTemplate.text()) + self.options["rename"]["issue_number_padding"] = int(self.leIssueNumPadding.text()) + self.options["rename"]["use_smart_string_cleanup"] = self.cbxSmartCleanup.isChecked() + self.options["rename"]["set_extension_based_on_archive"] = self.cbxChangeExtension.isChecked() + self.options["rename"]["move_to_dir"] = self.cbxMoveFiles.isChecked() + self.options["rename"]["dir"] = self.leDirectory.text() - self.settings.save() + self.options["rename"]["strict"] = self.cbxRenameStrict.isChecked() + + settings.Manager().save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") + self.parent().options = self.options QtWidgets.QDialog.accept(self) def select_rar(self) -> None: self.select_file(self.leRarExePath, "RAR") def clear_cache(self) -> None: - ImageFetcher().clear_cache() - ComicCacher(ComicTaggerSettings.get_settings_folder(), version).clear_cache() + ImageFetcher(self.options["runtime"]["config"].cache_folder).clear_cache() + ComicCacher(self.options["runtime"]["config"].cache_folder, version).clear_cache() QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.") def test_api_key(self) -> None: @@ -372,7 +382,7 @@ class SettingsWindow(QtWidgets.QDialog): QtWidgets.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.") def reset_settings(self) -> None: - self.settings.reset() + self.options = settings.Manager(self.options["option_definitions"]).defaults() self.settings_to_form() QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.") diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index 38d2e91..fb54d41 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -15,7 +15,6 @@ # limitations under the License. from __future__ import annotations -import argparse import json import logging import operator @@ -40,7 +39,7 @@ from comicapi.comicinfoxml import ComicInfoXml from comicapi.filenameparser import FileNameParser from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString -from comictaggerlib import ctversion +from comictaggerlib import ctversion, settings from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger from comictaggerlib.autotagmatchwindow import AutoTagMatchWindow from comictaggerlib.autotagprogresswindow import AutoTagProgressWindow @@ -58,7 +57,6 @@ from comictaggerlib.pagebrowser import PageBrowserWindow from comictaggerlib.pagelisteditor import PageListEditor from comictaggerlib.renamewindow import RenameWindow from comictaggerlib.resulttypes import IssueResult, MultipleMatch, OnlineMatchResults -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.settingswindow import SettingsWindow from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import center_window_on_parent, reduce_widget_font_size @@ -80,25 +78,26 @@ class TaggerWindow(QtWidgets.QMainWindow): def __init__( self, file_list: list[str], - settings: ComicTaggerSettings, + options: settings.OptionValues, talker_api: ComicTalker, parent: QtWidgets.QWidget | None = None, - opts: argparse.Namespace | None = None, ) -> None: super().__init__(parent) uic.loadUi(ui_path / "taggerwindow.ui", self) - self.settings = settings + self.options = options + if not options: + self.options = {} self.talker_api = talker_api self.log_window = self.setup_logger() # prevent multiple instances socket = QtNetwork.QLocalSocket(self) - socket.connectToServer(settings.install_id) + socket.connectToServer(options["internal"]["install_id"]) alive = socket.waitForConnected(3000) if alive: logger.setLevel(logging.INFO) - logger.info("Another application with key [%s] is already running", settings.install_id) + logger.info("Another application with key [%s] is already running", options["internal"]["install_id"]) # send file list to other instance if file_list: socket.write(pickle.dumps(file_list)) @@ -110,20 +109,20 @@ class TaggerWindow(QtWidgets.QMainWindow): # listen on a socket to prevent multiple instances self.socketServer = QtNetwork.QLocalServer(self) self.socketServer.newConnection.connect(self.on_incoming_socket_connection) - ok = self.socketServer.listen(settings.install_id) + ok = self.socketServer.listen(options["internal"]["install_id"]) if not ok: if self.socketServer.serverError() == QtNetwork.QAbstractSocket.SocketError.AddressInUseError: - self.socketServer.removeServer(settings.install_id) - ok = self.socketServer.listen(settings.install_id) + self.socketServer.removeServer(options["internal"]["install_id"]) + ok = self.socketServer.listen(options["internal"]["install_id"]) if not ok: logger.error( "Cannot start local socket with key [%s]. Reason: %s", - settings.install_id, + options["internal"]["install_id"], self.socketServer.errorString(), ) sys.exit() - self.archiveCoverWidget = CoverImageWidget(self.coverImageContainer, talker_api, CoverImageWidget.ArchiveMode) + self.archiveCoverWidget = CoverImageWidget(self.coverImageContainer, CoverImageWidget.ArchiveMode, None, None) grid_layout = QtWidgets.QGridLayout(self.coverImageContainer) grid_layout.addWidget(self.archiveCoverWidget) grid_layout.setContentsMargins(0, 0, 0, 0) @@ -132,14 +131,15 @@ class TaggerWindow(QtWidgets.QMainWindow): grid_layout = QtWidgets.QGridLayout(self.tabPages) grid_layout.addWidget(self.page_list_editor) - self.fileSelectionList = FileSelectionList(self.widgetListHolder, self.settings, self.dirty_flag_verification) + self.fileSelectionList = FileSelectionList(self.widgetListHolder, self.options, self.dirty_flag_verification) grid_layout = QtWidgets.QGridLayout(self.widgetListHolder) grid_layout.addWidget(self.fileSelectionList) self.fileSelectionList.selectionChanged.connect(self.file_list_selection_changed) self.fileSelectionList.listCleared.connect(self.file_list_cleared) self.fileSelectionList.set_sorting( - self.settings.last_filelist_sorted_column, QtCore.Qt.SortOrder(self.settings.last_filelist_sorted_order) + self.options["internal"]["last_filelist_sorted_column"], + QtCore.Qt.SortOrder(self.options["internal"]["last_filelist_sorted_order"]), ) # we can't specify relative font sizes in the UI designer, so @@ -156,14 +156,14 @@ class TaggerWindow(QtWidgets.QMainWindow): self.scrollAreaWidgetContents.adjustSize() self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) - # TODO: this needs to be looked at - if opts is not None and opts.type: - # respect the command line option tag type - settings.last_selected_save_data_style = opts.type[0] - settings.last_selected_load_data_style = opts.type[0] - self.save_data_style = settings.last_selected_save_data_style - self.load_data_style = settings.last_selected_load_data_style + if options["runtime"]["type"] and isinstance(options["runtime"]["type"][0], int): + # respect the command line option tag type + options["internal"]["last_selected_save_data_style"] = options["runtime"]["type"][0] + options["internal"]["last_selected_load_data_style"] = options["runtime"]["type"][0] + + self.save_data_style = options["internal"]["last_selected_save_data_style"] + self.load_data_style = options["internal"]["last_selected_load_data_style"] self.setAcceptDrops(True) self.config_menus() @@ -228,8 +228,10 @@ class TaggerWindow(QtWidgets.QMainWindow): self.show() self.set_app_position() - if self.settings.last_form_side_width != -1: - self.splitter.setSizes([self.settings.last_form_side_width, self.settings.last_list_side_width]) + if self.options["internal"]["last_form_side_width"] != -1: + self.splitter.setSizes( + [self.options["internal"]["last_form_side_width"], self.options["internal"]["last_list_side_width"]] + ) self.raise_() QtCore.QCoreApplication.processEvents() self.resizeEvent(None) @@ -246,7 +248,7 @@ class TaggerWindow(QtWidgets.QMainWindow): if len(file_list) != 0: self.fileSelectionList.add_path_list(file_list) - if self.settings.show_disclaimer: + if self.options["dialog"]["show_disclaimer"]: checked = OptionalMessageDialog.msg( self, "Welcome!", @@ -261,26 +263,9 @@ use ComicTagger on local copies of your comics.

Have fun! """, ) - self.settings.show_disclaimer = not checked + self.options["dialog"]["show_disclaimer"] = not checked - if self.settings.settings_warning < 4: - checked = OptionalMessageDialog.msg( - self, - "Warning!", - f""" -{" "*100} -The next release will save settings in a different format -no settings will be transfered to the new version.
-See https://github.com/comictagger/comictagger/releases/1.5.5 -for more information -

-You have {4-self.settings.settings_warning} warnings left. -
""", - ) - if checked: - self.settings.settings_warning += 1 - - if self.settings.check_for_new_version: + if self.options["general"]["check_for_new_version"]: self.check_latest_version_online() def open_file_event(self, url: QtCore.QUrl) -> None: @@ -293,7 +278,7 @@ You have {4-self.settings.settings_warning} warnings left. def setup_logger(self) -> ApplicationLogWindow: try: - current_logs = (ComicTaggerSettings.get_settings_folder() / "logs" / "ComicTagger.log").read_text("utf-8") + current_logs = (self.options["runtime"]["config"].user_log_dir / "ComicTagger.log").read_text("utf-8") except Exception: current_logs = "" root_logger = logging.getLogger() @@ -494,7 +479,6 @@ You have {4-self.settings.settings_warning} warnings left. if non_zip_count != 0: EW = ExportWindow( self, - self.settings, ( f"You have selected {non_zip_count} archive(s) to export to Zip format. " """ New archives will be created in the same folder as the original. @@ -627,10 +611,10 @@ You have {4-self.settings.settings_warning} warnings left. def actual_load_current_archive(self) -> None: if self.metadata.is_empty and self.comic_archive is not None: self.metadata = self.comic_archive.metadata_from_filename( - self.settings.complicated_parser, - self.settings.remove_c2c, - self.settings.remove_fcbd, - self.settings.remove_publisher, + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], ) if len(self.metadata.pages) == 0 and self.comic_archive is not None: self.metadata.set_default_page_list(self.comic_archive.get_number_of_pages()) @@ -998,10 +982,10 @@ You have {4-self.settings.settings_warning} warnings left. # copy the form onto metadata object self.form_to_metadata() new_metadata = self.comic_archive.metadata_from_filename( - self.settings.complicated_parser, - self.settings.remove_c2c, - self.settings.remove_fcbd, - self.settings.remove_publisher, + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], split_words, ) if new_metadata is not None: @@ -1022,8 +1006,8 @@ You have {4-self.settings.settings_warning} warnings left. else: dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFiles) - if self.settings.last_opened_folder is not None: - dialog.setDirectory(self.settings.last_opened_folder) + if self.options["internal"]["last_opened_folder"] is not None: + dialog.setDirectory(self.options["internal"]["last_opened_folder"]) if not folder_mode: archive_filter = "Comic archive files (*.cbz *.zip *.cbr *.rar *.cb7 *.7z)" @@ -1073,7 +1057,7 @@ You have {4-self.settings.settings_warning} warnings left. issue_count, cover_index_list, self.comic_archive, - self.settings, + self.options, self.talker_api, autoselect, literal, @@ -1092,16 +1076,9 @@ You have {4-self.settings.settings_warning} warnings left. self.form_to_metadata() try: - if selector.issue_id: - new_metadata = self.talker_api.fetch_comic_data(selector.issue_id) - elif selector.volume_id and selector.issue_number: - # Would this ever be needed? - new_metadata = self.talker_api.fetch_comic_data( - series_id=selector.volume_id, issue_number=selector.issue_number - ) - else: - # Only left with series? Isn't series only handled elsewhere? - new_metadata = self.talker_api.fetch_comic_data(series_id=selector.volume_id) + new_metadata = self.talker_api.fetch_comic_data( + issue_id=selector.issue_id or 0, series_id=selector.volume_id, issue_number=selector.issue_number + ) except TalkerError as e: QtWidgets.QApplication.restoreOverrideCursor() QtWidgets.QMessageBox.critical( @@ -1112,10 +1089,10 @@ You have {4-self.settings.settings_warning} warnings left. else: QtWidgets.QApplication.restoreOverrideCursor() if new_metadata is not None: - if self.settings.apply_cbl_transform_on_cv_import: - new_metadata = CBLTransformer(new_metadata, self.settings).apply() + if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: + new_metadata = CBLTransformer(new_metadata, self.options).apply() - if self.settings.clear_form_before_populating_from_cv: + if self.options["comicvine"]["clear_form_before_populating_from_cv"]: self.clear_form() notes = ( @@ -1170,7 +1147,7 @@ You have {4-self.settings.settings_warning} warnings left. "Change Tag Read Style", "If you change read tag style now, data in the form will be lost. Are you sure?" ): self.load_data_style = self.cbLoadDataStyle.itemData(s) - self.settings.last_selected_load_data_style = self.load_data_style + self.options["internal"]["last_selected_load_data_style"] = self.load_data_style self.update_menus() if self.comic_archive is not None: self.load_archive(self.comic_archive) @@ -1181,7 +1158,7 @@ You have {4-self.settings.settings_warning} warnings left. def set_save_data_style(self, s: int) -> None: self.save_data_style = self.cbSaveDataStyle.itemData(s) - self.settings.last_selected_save_data_style = self.save_data_style + self.options["internal"]["last_selected_save_data_style"] = self.save_data_style self.update_style_tweaks() self.update_menus() @@ -1400,15 +1377,17 @@ You have {4-self.settings.settings_warning} warnings left. def show_settings(self) -> None: - settingswin = SettingsWindow(self, self.settings, self.talker_api) + settingswin = SettingsWindow(self, self.options, self.talker_api) settingswin.setModal(True) settingswin.exec() settingswin.result() def set_app_position(self) -> None: - if self.settings.last_main_window_width != 0: - self.move(self.settings.last_main_window_x, self.settings.last_main_window_y) - self.resize(self.settings.last_main_window_width, self.settings.last_main_window_height) + if self.options["internal"]["last_main_window_width"] != 0: + self.move(self.options["internal"]["last_main_window_x"], self.options["internal"]["last_main_window_y"]) + self.resize( + self.options["internal"]["last_main_window_width"], self.options["internal"]["last_main_window_height"] + ) else: screen = QtGui.QGuiApplication.primaryScreen().geometry() size = self.frameGeometry() @@ -1675,8 +1654,11 @@ You have {4-self.settings.settings_warning} warnings left. if ca.has_metadata(src_style) and ca.is_writable(): md = ca.read_metadata(src_style) - if dest_style == MetaDataStyle.CBI and self.settings.apply_cbl_transform_on_bulk_operation: - md = CBLTransformer(md, self.settings).apply() + if ( + dest_style == MetaDataStyle.CBI + and self.options["cbl"]["apply_cbl_transform_on_bulk_operation"] + ): + md = CBLTransformer(md, self.options).apply() if not ca.write_metadata(md, dest_style): failed_list.append(ca.path) @@ -1710,12 +1692,12 @@ You have {4-self.settings.settings_warning} warnings left. try: ct_md = self.talker_api.fetch_comic_data(match["issue_id"]) - except TalkerError as e: - logger.exception(f"Save aborted.\n{e}") + except TalkerError: + logger.exception("Save aborted.") if not ct_md.is_empty: - if self.settings.apply_cbl_transform_on_cv_import: - ct_md = CBLTransformer(ct_md, self.settings).apply() + if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: + ct_md = CBLTransformer(ct_md, self.options).apply() QtWidgets.QApplication.restoreOverrideCursor() @@ -1734,7 +1716,7 @@ You have {4-self.settings.settings_warning} warnings left. self, ca: ComicArchive, match_results: OnlineMatchResults, dlg: AutoTagStartWindow ) -> tuple[bool, OnlineMatchResults]: success = False - ii = IssueIdentifier(ca, self.settings, self.talker_api) + ii = IssueIdentifier(ca, self.options, self.talker_api) # read in metadata, and parse file name if not there try: @@ -1744,10 +1726,10 @@ You have {4-self.settings.settings_warning} warnings left. logger.error("Failed to load metadata for %s: %s", ca.path, e) if md.is_empty: md = ca.metadata_from_filename( - self.settings.complicated_parser, - self.settings.remove_c2c, - self.settings.remove_fcbd, - self.settings.remove_publisher, + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], dlg.split_words, ) if dlg.ignore_leading_digits_in_filename and md.series is not None: @@ -1833,7 +1815,7 @@ You have {4-self.settings.settings_warning} warnings left. ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if self.settings.auto_imprint: + if self.options["comicvine"]["auto_imprint"]: md.fix_publisher() if not ca.write_metadata(md, self.save_data_style): @@ -1862,7 +1844,7 @@ You have {4-self.settings.settings_warning} warnings left. atstartdlg = AutoTagStartWindow( self, - self.settings, + self.options, ( f"You have selected {len(ca_list)} archive(s) to automatically identify and write {MetaDataStyle.name[style]} tags to." "\n\nPlease choose options below, and select OK to Auto-Tag." @@ -1965,7 +1947,7 @@ You have {4-self.settings.settings_warning} warnings left. match_results.multiple_matches, style, self.actual_issue_data_fetch, - self.settings, + self.options, self.talker_api, ) matchdlg.setModal(True) @@ -2011,17 +1993,19 @@ You have {4-self.settings.settings_warning} warnings left. f"Exit {self.appName}", "If you quit now, data in the form will be lost. Are you sure?" ): appsize = self.size() - self.settings.last_main_window_width = appsize.width() - self.settings.last_main_window_height = appsize.height() - self.settings.last_main_window_x = self.x() - self.settings.last_main_window_y = self.y() - self.settings.last_form_side_width = self.splitter.sizes()[0] - self.settings.last_list_side_width = self.splitter.sizes()[1] + self.options["internal"]["last_main_window_width"] = appsize.width() + self.options["internal"]["last_main_window_height"] = appsize.height() + self.options["internal"]["last_main_window_x"] = self.x() + self.options["internal"]["last_main_window_y"] = self.y() + self.options["internal"]["last_form_side_width"] = self.splitter.sizes()[0] + self.options["internal"]["last_list_side_width"] = self.splitter.sizes()[1] ( - self.settings.last_filelist_sorted_column, - self.settings.last_filelist_sorted_order, + self.options["internal"]["last_filelist_sorted_column"], + self.options["internal"]["last_filelist_sorted_order"], ) = self.fileSelectionList.get_sorting() - self.settings.save() + settings.Manager().save_file( + self.options, self.options["runtime"]["config"].user_config_dir / "settings.json" + ) event.accept() else: @@ -2070,7 +2054,7 @@ You have {4-self.settings.settings_warning} warnings left. def apply_cbl_transform(self) -> None: self.form_to_metadata() - self.metadata = CBLTransformer(self.metadata, self.settings).apply() + self.metadata = CBLTransformer(self.metadata, self.options).apply() self.metadata_to_form() def recalc_page_dimensions(self) -> None: @@ -2096,7 +2080,7 @@ You have {4-self.settings.settings_warning} warnings left. "File Rename", "If you rename files now, unsaved data in the form will be lost. Are you sure?" ): - dlg = RenameWindow(self, ca_list, self.load_data_style, self.settings, self.talker_api) + dlg = RenameWindow(self, ca_list, self.load_data_style, self.options, self.talker_api) dlg.setModal(True) if dlg.exec() and self.comic_archive is not None: self.fileSelectionList.update_selected_rows() @@ -2115,7 +2099,7 @@ You have {4-self.settings.settings_warning} warnings left. QtCore.QTimer.singleShot(1, self.fileSelectionList.revert_selection) return - self.settings.last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0]) + self.options["internal"]["last_opened_folder"] = os.path.abspath(os.path.split(comic_archive.path)[0]) self.comic_archive = comic_archive try: self.metadata = self.comic_archive.read_metadata(self.load_data_style) @@ -2147,11 +2131,13 @@ You have {4-self.settings.settings_warning} warnings left. def check_latest_version_online(self) -> None: version_checker = VersionChecker() self.version_check_complete( - version_checker.get_latest_version(self.settings.install_id, self.settings.send_usage_stats) + version_checker.get_latest_version( + self.options["internal"]["install_id"], self.options["general"]["send_usage_stats"] + ) ) def version_check_complete(self, new_version: tuple[str, str]) -> None: - if new_version[0] not in (self.version, self.settings.dont_notify_about_this_version): + if new_version[0] not in (self.version, self.options["dialog"]["dont_notify_about_this_version"]): website = "https://github.com/comictagger/comictagger" checked = OptionalMessageDialog.msg( self, @@ -2162,7 +2148,7 @@ You have {4-self.settings.settings_warning} warnings left. "Don't tell me about this version again", ) if checked: - self.settings.dont_notify_about_this_version = new_version[0] + self.options["dialog"]["dont_notify_about_this_version"] = new_version[0] def on_incoming_socket_connection(self) -> None: # Accept connection from other instance. diff --git a/comictaggerlib/volumeselectionwindow.py b/comictaggerlib/volumeselectionwindow.py index a7f2e60..03ff093 100644 --- a/comictaggerlib/volumeselectionwindow.py +++ b/comictaggerlib/volumeselectionwindow.py @@ -25,12 +25,12 @@ from PyQt5.QtCore import pyqtSignal from comicapi import utils from comicapi.comicarchive import ComicArchive from comicapi.genericmetadata import GenericMetadata +from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.issueidentifier import IssueIdentifier from comictaggerlib.issueselectionwindow import IssueSelectionWindow from comictaggerlib.matchselectionwindow import MatchSelectionWindow from comictaggerlib.progresswindow import IDProgressWindow -from comictaggerlib.settings import ComicTaggerSettings from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import reduce_widget_font_size from comictalker.resulttypes import ComicVolume @@ -111,7 +111,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): issue_count: int, cover_index_list: list[int], comic_archive: ComicArchive | None, - settings: ComicTaggerSettings, + options: settings.OptionValues, talker_api: ComicTalker, autoselect: bool = False, literal: bool = False, @@ -120,7 +120,9 @@ class VolumeSelectionWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "volumeselectionwindow.ui", self) - self.imageWidget = CoverImageWidget(self.imageContainer, talker_api, CoverImageWidget.URLMode) + self.imageWidget = CoverImageWidget( + self.imageContainer, CoverImageWidget.URLMode, options["runtime"]["config"].user_cache_dir, talker_api + ) gridlayout = QtWidgets.QGridLayout(self.imageContainer) gridlayout.addWidget(self.imageWidget) gridlayout.setContentsMargins(0, 0, 0, 0) @@ -136,7 +138,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): ) ) - self.settings = settings + self.options = options self.series_name = series_name self.issue_number = issue_number self.issue_id: int | None = None @@ -154,7 +156,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): self.progdialog: QtWidgets.QProgressDialog | None = None self.search_thread: SearchThread | None = None - self.use_filter = self.settings.always_use_publisher_filter + self.use_filter = self.options["comicvine"]["always_use_publisher_filter"] # Load to retrieve settings self.talker_api = talker_api @@ -205,7 +207,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): self.iddialog.rejected.connect(self.identify_cancel) self.iddialog.show() - self.ii = IssueIdentifier(self.comic_archive, self.settings, self.talker_api) + self.ii = IssueIdentifier(self.comic_archive, self.options, self.talker_api) md = GenericMetadata() md.series = self.series_name @@ -278,7 +280,9 @@ class VolumeSelectionWindow(QtWidgets.QDialog): choices = True if choices: - selector = MatchSelectionWindow(self, matches, self.comic_archive, self.talker_api) + selector = MatchSelectionWindow( + self, matches, self.comic_archive, talker_api=self.talker_api, options=self.options + ) selector.setModal(True) selector.exec() if selector.result(): @@ -294,7 +298,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): self.show_issues() def show_issues(self) -> None: - selector = IssueSelectionWindow(self, self.settings, self.talker_api, self.volume_id, self.issue_number) + selector = IssueSelectionWindow(self, self.options, self.talker_api, self.volume_id, self.issue_number) title = "" for record in self.ct_search_results: if record["id"] == self.volume_id: @@ -322,7 +326,11 @@ class VolumeSelectionWindow(QtWidgets.QDialog): def perform_query(self, refresh: bool = False) -> None: self.search_thread = SearchThread( - self.talker_api, self.series_name, refresh, self.literal, self.settings.id_series_match_search_thresh + self.talker_api, + self.series_name, + refresh, + self.literal, + self.options["comicvine"]["series_match_search_thresh"], ) self.search_thread.searchComplete.connect(self.search_complete) self.search_thread.progressUpdate.connect(self.search_progress_update) @@ -360,7 +368,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): def search_complete(self) -> None: if self.progdialog is not None: self.progdialog.accept() - self.progdialog = None + del self.progdialog if self.search_thread is not None and self.search_thread.ct_error: # TODO Currently still opens the window QtWidgets.QMessageBox.critical( @@ -374,7 +382,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): # filter the publishers if enabled set if self.use_filter: try: - publisher_filter = {s.strip().casefold() for s in self.settings.id_publisher_filter.split(",")} + publisher_filter = {s.strip().casefold() for s in self.options["identifier"]["publisher_filter"]} # use '' as publisher name if None self.ct_search_results = list( filter( @@ -390,7 +398,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): # compare as str in case extra chars ie. '1976?' # - missing (none) values being converted to 'None' - consistent with prior behaviour in v1.2.3 # sort by start_year if set - if self.settings.sort_series_by_year: + if self.options["comicvine"]["sort_series_by_year"]: try: self.ct_search_results = sorted( self.ct_search_results, @@ -408,7 +416,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): logger.exception("bad data error sorting results by count_of_issues") # move sanitized matches to the front - if self.settings.exact_series_matches_first: + if self.options["comicvine"]["exact_series_matches_first"]: try: sanitized = utils.sanitize_title(self.series_name, False).casefold() sanitized_no_articles = utils.sanitize_title(self.series_name, True).casefold() diff --git a/requirements.txt b/requirements.txt index 844c274..972345f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +appdirs==1.4.4 beautifulsoup4>=4.1 importlib_metadata>=3.3.0 natsort>=8.1.0 diff --git a/setup.py b/setup.py index 3da2031..a2b00ab 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ import os from setuptools import setup -def read(fname): +def read(fname: str) -> str: """ Read the contents of a file. Parameters diff --git a/tests/comiccacher_test.py b/tests/comiccacher_test.py index 7eeb314..547f699 100644 --- a/tests/comiccacher_test.py +++ b/tests/comiccacher_test.py @@ -7,8 +7,8 @@ from testing.comicdata import search_results def test_create_cache(settings, mock_version): - comictalker.comiccacher.ComicCacher(settings.get_settings_folder(), mock_version[0]) - assert (settings.get_settings_folder() / "settings").exists() + comictalker.comiccacher.ComicCacher(settings["runtime"]["config"].user_cache_dir, mock_version[0]) + assert (settings["runtime"]["config"].user_cache_dir).exists() def test_search_results(comic_cache): diff --git a/tests/conftest.py b/tests/conftest.py index 2eaa39c..a15df4a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ import comictaggerlib.settings import comictalker.comiccacher import comictalker.talkers.comicvine from comicapi import utils +from comictaggerlib import settings as ctsettings from testing import comicvine, filenames from testing.comicdata import all_seed_imprints, seed_imprints @@ -116,7 +117,7 @@ def comicvine_api( cv = comictalker.talkers.comicvine.ComicVineTalker( version=mock_version[0], - cache_folder=settings.get_settings_folder(), + cache_folder=settings["runtime"]["config"].user_cache_dir, api_url="", api_key="", series_match_thresh=90, @@ -163,9 +164,20 @@ def seed_all_publishers(monkeypatch): @pytest.fixture def settings(tmp_path): - yield comictaggerlib.settings.ComicTaggerSettings(tmp_path / "settings") + + manager = ctsettings.Manager() + ctsettings.register_commandline(manager) + ctsettings.register_settings(manager) + defaults = manager.defaults() + defaults["runtime"]["config"] = ctsettings.ComicTaggerPaths(tmp_path / "config") + defaults["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) + defaults["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) + defaults["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) + defaults["runtime"]["config"].user_state_dir.mkdir(parents=True, exist_ok=True) + defaults["runtime"]["config"].user_log_dir.mkdir(parents=True, exist_ok=True) + yield defaults @pytest.fixture def comic_cache(settings, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]: - yield comictalker.comiccacher.ComicCacher(settings.get_settings_folder(), mock_version[0]) + yield comictalker.comiccacher.ComicCacher(settings["runtime"]["config"].user_cache_dir, mock_version[0]) From d0719e720110ed186bc366d832867e38b293ad63 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Wed, 23 Nov 2022 22:14:09 -0800 Subject: [PATCH 02/22] Fix log dir --- comictaggerlib/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 7c0ea9a..45951d2 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -77,7 +77,7 @@ class App: def initialize(self) -> argparse.Namespace: opts, _ = self.initial_arg_parser.parse_known_args() assert opts is not None - setup_logging(opts.verbose, opts.config.user_log_dir / "log") + setup_logging(opts.verbose, opts.config.user_log_dir) return opts def register_options(self) -> None: From 82d737407ff82c89950a447ad4c36009f8891dc5 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Wed, 23 Nov 2022 22:15:59 -0800 Subject: [PATCH 03/22] Simplify --only-set-cv-key --- comictaggerlib/main.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 45951d2..0081d4a 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -109,16 +109,6 @@ class App: # manage the CV API key # None comparison is used so that the empty string can unset the value if self.options["comicvine"]["cv_api_key"] is not None or self.options["comicvine"]["cv_url"] is not None: - self.options["comicvine"]["cv_api_key"] = ( - self.options["comicvine"]["cv_api_key"] - if self.options["comicvine"]["cv_api_key"] is not None - else self.options["comicvine"]["cv_api_key"] - ) - self.options["comicvine"]["cv_url"] = ( - self.options["comicvine"]["cv_url"] - if self.options["comicvine"]["cv_url"] is not None - else self.options["comicvine"]["cv_url"] - ) self.manager.save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") logger.debug(pprint.pformat(self.options)) if self.options["commands"]["only_set_cv_key"]: From ed1df400d8ce0c37f0b62906c0e79cdc262c9e21 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Wed, 23 Nov 2022 22:16:46 -0800 Subject: [PATCH 04/22] Add replacement settings --- comictaggerlib/cli.py | 6 +- comictaggerlib/defaults.py | 29 ++++++ comictaggerlib/filerenamer.py | 40 +++------ comictaggerlib/renamewindow.py | 3 +- comictaggerlib/settings/file.py | 83 +++-------------- comictaggerlib/settings/types.py | 68 +++++++++++++- comictaggerlib/settingswindow.py | 93 +++++++++++++++++-- comictaggerlib/ui/settingswindow.ui | 133 +++++++++++++++++++++++++++- 8 files changed, 340 insertions(+), 115 deletions(-) create mode 100644 comictaggerlib/defaults.py diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index f2424fe..c9bbce9 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -502,7 +502,11 @@ def process_file_cli( elif ca.is_rar(): new_ext = ".cbr" - renamer = FileRenamer(md, platform="universal" if options[filename]["rename_strict"] else "auto") + renamer = FileRenamer( + md, + platform="universal" if options[filename]["rename_strict"] else "auto", + replacements=options["rename"]["replacements"], + ) renamer.set_template(options[filename]["rename_template"]) renamer.set_issue_zero_padding(options[filename]["rename_issue_number_padding"]) renamer.set_smart_cleanup(options[filename]["rename_use_smart_string_cleanup"]) diff --git a/comictaggerlib/defaults.py b/comictaggerlib/defaults.py new file mode 100644 index 0000000..8717d12 --- /dev/null +++ b/comictaggerlib/defaults.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import NamedTuple + + +class Replacement(NamedTuple): + find: str + replce: str + strict_only: bool + + +class Replacements(NamedTuple): + literal_text: list[Replacement] + format_value: list[Replacement] + + +DEFAULT_REPLACEMENTS = Replacements( + literal_text=[ + Replacement(": ", " - ", True), + Replacement(":", "-", True), + ], + format_value=[ + Replacement(": ", " - ", True), + Replacement(":", "-", True), + Replacement("/", "-", False), + Replacement("//", "--", False), + Replacement("\\", "-", True), + ], +) diff --git a/comictaggerlib/filerenamer.py b/comictaggerlib/filerenamer.py index d52214f..828f8f2 100644 --- a/comictaggerlib/filerenamer.py +++ b/comictaggerlib/filerenamer.py @@ -20,42 +20,18 @@ import logging import os import pathlib import string -from typing import Any, NamedTuple, cast +from typing import Any, cast from pathvalidate import Platform, normalize_platform, sanitize_filename from comicapi.comicarchive import ComicArchive from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString +from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements logger = logging.getLogger(__name__) -class Replacement(NamedTuple): - find: str - replce: str - strict_only: bool - - -class Replacements(NamedTuple): - literal_text: list[Replacement] - format_value: list[Replacement] - - -REPLACEMENTS = Replacements( - literal_text=[ - Replacement(": ", " - ", True), - Replacement(":", "-", True), - ], - format_value=[ - Replacement(": ", " - ", True), - Replacement(":", "-", True), - Replacement("/", "-", False), - Replacement("\\", "-", True), - ], -) - - def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> pathlib.Path: folder = ca.path.parent.absolute() if rename_dir is not None: @@ -67,7 +43,7 @@ def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> p class MetadataFormatter(string.Formatter): def __init__( - self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = REPLACEMENTS + self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = DEFAULT_REPLACEMENTS ) -> None: super().__init__() self.smart_cleanup = smart_cleanup @@ -200,13 +176,19 @@ class MetadataFormatter(string.Formatter): class FileRenamer: - def __init__(self, metadata: GenericMetadata | None, platform: str = "auto") -> None: + def __init__( + self, + metadata: GenericMetadata | None, + platform: str = "auto", + replacements: Replacements = DEFAULT_REPLACEMENTS, + ) -> None: self.template = "{publisher}/{series}/{series} v{volume} #{issue} (of {issue_count}) ({year})" self.smart_cleanup = True self.issue_zero_padding = 3 self.metadata = metadata or GenericMetadata() self.move = False self.platform = platform + self.replacements = replacements def set_metadata(self, metadata: GenericMetadata) -> None: self.metadata = metadata @@ -234,7 +216,7 @@ class FileRenamer: new_name = "" - fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform) + fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform, replacements=self.replacements) md_dict = vars(md) for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]: md_dict[role] = md.get_primary_credit(role) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index f231618..a3f3d78 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -62,7 +62,7 @@ class RenameWindow(QtWidgets.QDialog): self.btnSettings.clicked.connect(self.modify_settings) platform = "universal" if self.options["filename"]["rename_strict"] else "auto" - self.renamer = FileRenamer(None, platform=platform) + self.renamer = FileRenamer(None, platform=platform, replacements=self.options["rename"]["replacements"]) self.do_preview() @@ -70,6 +70,7 @@ class RenameWindow(QtWidgets.QDialog): self.renamer.set_template(self.options["filename"]["rename_template"]) self.renamer.set_issue_zero_padding(self.options["filename"]["rename_issue_number_padding"]) self.renamer.set_smart_cleanup(self.options["filename"]["rename_use_smart_string_cleanup"]) + self.renamer.replacements = self.options["rename"]["replacements"] new_ext = ca.path.suffix # default if self.options["filename"]["rename_set_extension_based_on_archive"]: diff --git a/comictaggerlib/settings/file.py b/comictaggerlib/settings/file.py index 36931f5..bafa90c 100644 --- a/comictaggerlib/settings/file.py +++ b/comictaggerlib/settings/file.py @@ -2,10 +2,11 @@ from __future__ import annotations import argparse import uuid -from collections.abc import Sequence -from typing import Any, Callable +from typing import Any +from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements from comictaggerlib.settings.manager import Manager +from comictaggerlib.settings.types import AppendAction def general(parser: Manager) -> None: @@ -38,75 +39,10 @@ def identifier(parser: Manager) -> None: parser.add_setting( "--publisher-filter", default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"], - action=_AppendAction, + action=AppendAction, ) -def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]: - if items is None: - return [] - # The copy module is used only in the 'append' and 'append_const' - # actions, and it is needed only when the default value isn't a list. - # Delay its import for speeding up the common case. - if type(items) is list: - return items[:] - import copy - - return copy.copy(items) - - -class _AppendAction(argparse.Action): - def __init__( - self, - option_strings: list[str], - dest: str, - nargs: str | None = None, - const: Any = None, - default: Any = None, - type: Callable[[str], Any] | None = None, # noqa: A002 - choices: list[Any] | None = None, - required: bool = False, - help: str | None = None, # noqa: A002 - metavar: str | None = None, - ): - self.called = False - if nargs == 0: - raise ValueError( - "nargs for append actions must be != 0; if arg " - "strings are not supplying the value to append, " - "the append const action may be more appropriate" - ) - if const is not None and nargs != argparse.OPTIONAL: - raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL) - super().__init__( - option_strings=option_strings, - dest=dest, - nargs=nargs, - const=const, - default=default, - type=type, - choices=choices, - required=required, - help=help, - metavar=metavar, - ) - - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: str | Sequence[Any] | None, - option_string: str | None = None, - ) -> None: - if values: - if not self.called: - setattr(namespace, self.dest, []) - items = getattr(namespace, self.dest, None) - items = _copy_items(items) - items.append(values) # type: ignore - setattr(namespace, self.dest, items) - - def dialog(parser: Manager) -> None: # Show/ask dialog flags parser.add_setting("ask_about_cbi_in_rar", default=True, cmdline=False) @@ -140,12 +76,10 @@ def comicvine(parser: Manager) -> None: parser.add_setting("--remove-html-tables", default=False, action=argparse.BooleanOptionalAction) parser.add_setting( "--cv-api-key", - default="", help="Use the given Comic Vine API Key (persisted in settings).", ) parser.add_setting( "--cv-url", - default="", help="Use the given Comic Vine URL (persisted in settings).", ) parser.add_setting( @@ -184,6 +118,11 @@ def rename(parser: Manager) -> None: parser.add_setting("--dir", default="") parser.add_setting("--move-to-dir", default=False, action=argparse.BooleanOptionalAction) parser.add_setting("--strict", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "replacements", + default=DEFAULT_REPLACEMENTS, + cmdline=False, + ) def autotag(parser: Manager) -> None: @@ -214,6 +153,10 @@ def validate_settings(options: dict[str, dict[str, Any]], parser: Manager) -> di options["identifier"]["publisher_filter"] = [ x.strip() for x in options["identifier"]["publisher_filter"] if x.strip() ] + options["rename"]["replacements"] = Replacements( + [Replacement(x[0], x[1], x[2]) for x in options["rename"]["replacements"][0]], + [Replacement(x[0], x[1], x[2]) for x in options["rename"]["replacements"][1]], + ) return options diff --git a/comictaggerlib/settings/types.py b/comictaggerlib/settings/types.py index 774fbdb..7028e89 100644 --- a/comictaggerlib/settings/types.py +++ b/comictaggerlib/settings/types.py @@ -2,7 +2,8 @@ from __future__ import annotations import argparse import pathlib -from typing import Any +from collections.abc import Sequence +from typing import Any, Callable from appdirs import AppDirs @@ -72,6 +73,71 @@ def metadata_type(types: str) -> list[int]: return result +def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]: + if items is None: + return [] + # The copy module is used only in the 'append' and 'append_const' + # actions, and it is needed only when the default value isn't a list. + # Delay its import for speeding up the common case. + if type(items) is list: + return items[:] + import copy + + return copy.copy(items) + + +class AppendAction(argparse.Action): + def __init__( + self, + option_strings: list[str], + dest: str, + nargs: str | None = None, + const: Any = None, + default: Any = None, + type: Callable[[str], Any] | None = None, # noqa: A002 + choices: list[Any] | None = None, + required: bool = False, + help: str | None = None, # noqa: A002 + metavar: str | None = None, + ): + self.called = False + if nargs == 0: + raise ValueError( + "nargs for append actions must be != 0; if arg " + "strings are not supplying the value to append, " + "the append const action may be more appropriate" + ) + if const is not None and nargs != argparse.OPTIONAL: + raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL) + super().__init__( + option_strings=option_strings, + dest=dest, + nargs=nargs, + const=const, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar, + ) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + if values: + if not self.called: + setattr(namespace, self.dest, []) + items = getattr(namespace, self.dest, None) + items = _copy_items(items) + items.append(values) # type: ignore + setattr(namespace, self.dest, items) + + def parse_metadata_from_string(mdstr: str) -> GenericMetadata: """The metadata string is a comma separated list of name-value pairs The names match the attributes of the internal metadata struct (for now) diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index b786869..daf585e 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -20,6 +20,7 @@ import logging import os import pathlib import platform +from typing import Any from PyQt5 import QtCore, QtGui, QtWidgets, uic @@ -27,7 +28,7 @@ from comicapi import utils from comicapi.genericmetadata import md_test from comictaggerlib import settings from comictaggerlib.ctversion import version -from comictaggerlib.filerenamer import FileRenamer +from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements from comictaggerlib.imagefetcher import ImageFetcher from comictaggerlib.ui import ui_path from comictalker.comiccacher import ComicCacher @@ -190,23 +191,64 @@ class SettingsWindow(QtWidgets.QDialog): self.btnResetSettings.clicked.connect(self.reset_settings) self.btnTestKey.clicked.connect(self.test_api_key) self.btnTemplateHelp.clicked.connect(self.show_template_help) - self.leRenameTemplate.textEdited.connect(self._rename_test) - self.cbxMoveFiles.clicked.connect(self.rename_test) self.cbxMoveFiles.clicked.connect(self.dir_test) - self.cbxRenameStrict.clicked.connect(self.rename_test) self.leDirectory.textEdited.connect(self.dir_test) self.cbxComplicatedParser.clicked.connect(self.switch_parser) - def rename_test(self) -> None: + self.btnAddLiteralReplacement.clicked.connect(self.addLiteralReplacement) + self.btnAddValueReplacement.clicked.connect(self.addValueReplacement) + self.btnRemoveLiteralReplacement.clicked.connect(self.removeLiteralReplacement) + self.btnRemoveValueReplacement.clicked.connect(self.removeValueReplacement) + + self.leRenameTemplate.textEdited.connect(self.rename_test) + self.cbxMoveFiles.clicked.connect(self.rename_test) + self.cbxRenameStrict.clicked.connect(self.rename_test) + self.cbxSmartCleanup.clicked.connect(self.rename_test) + self.cbxChangeExtension.clicked.connect(self.rename_test) + self.leIssueNumPadding.textEdited.connect(self.rename_test) + self.twLiteralReplacements.cellChanged.connect(self.rename_test) + self.twValueReplacements.cellChanged.connect(self.rename_test) + + def addLiteralReplacement(self) -> None: + self.insertRow(self.twLiteralReplacements, self.twLiteralReplacements.rowCount(), Replacement("", "", False)) + + def addValueReplacement(self) -> None: + self.insertRow(self.twValueReplacements, self.twValueReplacements.rowCount(), Replacement("", "", False)) + + def removeLiteralReplacement(self) -> None: + if self.twLiteralReplacements.currentRow() >= 0: + self.twLiteralReplacements.removeRow(self.twLiteralReplacements.currentRow()) + + def removeValueReplacement(self) -> None: + if self.twValueReplacements.currentRow() >= 0: + self.twValueReplacements.removeRow(self.twValueReplacements.currentRow()) + + def insertRow(self, table: QtWidgets.QTableWidget, row: int, replacement: Replacement) -> None: + find, replace, strict_only = replacement + table.insertRow(row) + table.setItem(row, 0, QtWidgets.QTableWidgetItem(find)) + table.setItem(row, 1, QtWidgets.QTableWidgetItem(replace)) + tmp = QtWidgets.QTableWidgetItem() + if strict_only: + tmp.setCheckState(QtCore.Qt.Checked) + else: + tmp.setCheckState(QtCore.Qt.Unchecked) + table.setItem(row, 2, tmp) + + def rename_test(self, *args: Any, **kwargs: Any) -> None: self._rename_test(self.leRenameTemplate.text()) def dir_test(self) -> None: self.lblDir.setText( - str(pathlib.Path(self.leDirectory.text().strip()).absolute()) if self.cbxMoveFiles.isChecked() else "" + str(pathlib.Path(self.leDirectory.text().strip()).resolve()) if self.cbxMoveFiles.isChecked() else "" ) def _rename_test(self, template: str) -> None: - fr = FileRenamer(md_test, platform="universal" if self.cbxRenameStrict.isChecked() else "auto") + fr = FileRenamer( + md_test, + platform="universal" if self.cbxRenameStrict.isChecked() else "auto", + replacements=self.get_replacemnts(), + ) fr.move = self.cbxMoveFiles.isChecked() fr.set_template(template) fr.set_issue_zero_padding(int(self.leIssueNumPadding.text())) @@ -271,6 +313,38 @@ class SettingsWindow(QtWidgets.QDialog): self.leDirectory.setText(self.options["rename"]["dir"]) self.cbxRenameStrict.setChecked(self.options["rename"]["strict"]) + for table, replacments in zip( + (self.twLiteralReplacements, self.twValueReplacements), self.options["rename"]["replacements"] + ): + table.clearContents() + for i in reversed(range(table.rowCount())): + table.removeRow(i) + for row, replacement in enumerate(replacments): + self.insertRow(table, row, replacement) + + def get_replacemnts(self) -> Replacements: + literal_replacements = [] + value_replacements = [] + for row in range(self.twLiteralReplacements.rowCount()): + if self.twLiteralReplacements.item(row, 0).text(): + literal_replacements.append( + Replacement( + self.twLiteralReplacements.item(row, 0).text(), + self.twLiteralReplacements.item(row, 1).text(), + self.twLiteralReplacements.item(row, 2).checkState() == QtCore.Qt.Checked, + ) + ) + for row in range(self.twValueReplacements.rowCount()): + if self.twValueReplacements.item(row, 0).text(): + value_replacements.append( + Replacement( + self.twValueReplacements.item(row, 0).text(), + self.twValueReplacements.item(row, 1).text(), + self.twValueReplacements.item(row, 2).checkState() == QtCore.Qt.Checked, + ) + ) + return Replacements(literal_replacements, value_replacements) + def accept(self) -> None: self.rename_test() if self.rename_error is not None: @@ -362,6 +436,7 @@ class SettingsWindow(QtWidgets.QDialog): self.options["rename"]["dir"] = self.leDirectory.text() self.options["rename"]["strict"] = self.cbxRenameStrict.isChecked() + self.options["rename"]["replacements"] = self.get_replacemnts() settings.Manager().save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") self.parent().options = self.options @@ -402,9 +477,9 @@ class SettingsWindow(QtWidgets.QDialog): dialog.setDirectory(os.path.dirname(str(control.text()))) if name == "RAR": - dialog.setWindowTitle("Find " + name + " program") + dialog.setWindowTitle(f"Find {name} program") else: - dialog.setWindowTitle("Find " + name + " library") + dialog.setWindowTitle(f"Find {name} library") if dialog.exec(): file_list = dialog.selectedFiles() diff --git a/comictaggerlib/ui/settingswindow.ui b/comictaggerlib/ui/settingswindow.ui index 418bf4c..813c7a8 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -7,7 +7,7 @@ 0 0 702 - 488 + 513 @@ -616,7 +616,21 @@ Rename - + + + + Add Replacement + + + + + + + Remove Replacement + + + + QFormLayout::AllNonFixedFieldsGrow @@ -717,8 +731,7 @@ - If checked will ensure reserved characters and filenames are removed for all Operating Systems. -By default only removes restricted characters and filenames for the current Operating System. + If checked will ensure reserved characters and filenames are removed for all Operating Systems.<br/>By default only removes restricted characters and filenames for the current Operating System. Strict renaming @@ -730,6 +743,118 @@ By default only removes restricted characters and filenames for the current Oper + + + + Add Replacement + + + + + + + Remove Replacement + + + + + + + + Monaco + + + + QAbstractItemView::SingleSelection + + + true + + + + Find + + + AlignCenter + + + + + Replacement + + + AlignCenter + + + + + Strict Only + + + AlignCenter + + + + + + + + + Monaco + + + + Qt::ActionsContextMenu + + + QAbstractItemView::SingleSelection + + + 3 + + + true + + + + Find + + + AlignCenter + + + + + Replacement + + + AlignCenter + + + + + Strict Only + + + AlignCenter + + + + + + + + Value Text Replacements + + + + + + + Literal Text Replacements + + + From 3870cd0f5329e63e95bc8ee854d8333e13f2a75e Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Fri, 25 Nov 2022 19:18:24 -0800 Subject: [PATCH 05/22] Update help for --config --- comictaggerlib/settings/cmdline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comictaggerlib/settings/cmdline.py b/comictaggerlib/settings/cmdline.py index d84f403..1461595 100644 --- a/comictaggerlib/settings/cmdline.py +++ b/comictaggerlib/settings/cmdline.py @@ -52,7 +52,7 @@ def initial_cmd_line_parser() -> argparse.ArgumentParser: def register_options(parser: Manager) -> None: parser.add_setting( "--config", - help="Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n", + help="Config directory defaults to ~/.Config/ComicTagger\non Linux, ~/Library/Application Support/ComicTagger on Mac and %%APPDATA%%\\ComicTagger on Windows\n", type=ComicTaggerPaths, default=ComicTaggerPaths(), file=False, From da21dc110da211c74514c02c4575bdf0d7ef1c9c Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sat, 26 Nov 2022 16:04:40 -0800 Subject: [PATCH 06/22] Update help --- comictaggerlib/settings/file.py | 141 ++++++++++++++++++++++++++------ 1 file changed, 115 insertions(+), 26 deletions(-) diff --git a/comictaggerlib/settings/file.py b/comictaggerlib/settings/file.py index bafa90c..a5e9f00 100644 --- a/comictaggerlib/settings/file.py +++ b/comictaggerlib/settings/file.py @@ -11,8 +11,13 @@ from comictaggerlib.settings.types import AppendAction def general(parser: Manager) -> None: # General Settings - parser.add_setting("--rar-exe-path", default="rar") - parser.add_setting("--allow-cbi-in-rar", default=True, action=argparse.BooleanOptionalAction) + parser.add_setting("--rar-exe-path", default="rar", help="The path to the rar program") + parser.add_setting( + "--allow-cbi-in-rar", + default=True, + action=argparse.BooleanOptionalAction, + help="Allows ComicBookLover tags in RAR/CBR files", + ) parser.add_setting("check_for_new_version", default=False, cmdline=False) parser.add_setting("send_usage_stats", default=False, cmdline=False) @@ -35,11 +40,12 @@ def internal(parser: Manager) -> None: def identifier(parser: Manager) -> None: # identifier settings - parser.add_setting("--series-match-identify-thresh", default=91, type=int) + parser.add_setting("--series-match-identify-thresh", default=91, type=int, help="") parser.add_setting( "--publisher-filter", default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"], action=AppendAction, + help="When enabled filters the listed publishers from all search results", ) @@ -53,10 +59,30 @@ def dialog(parser: Manager) -> None: def filename(parser: Manager) -> None: # filename parsing settings - parser.add_setting("--complicated-parser", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--remove-c2c", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--remove-fcbd", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--remove-publisher", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--complicated-parser", + default=False, + action=argparse.BooleanOptionalAction, + help="Enables the new parser which tries to extract more information from filenames", + ) + parser.add_setting( + "--remove-c2c", + default=False, + action=argparse.BooleanOptionalAction, + help="Removes c2c from filenames. Requires --complicated-parser", + ) + parser.add_setting( + "--remove-fcbd", + default=False, + action=argparse.BooleanOptionalAction, + help="Removes FCBD/free comic book day from filenames. Requires --complicated-parser", + ) + parser.add_setting( + "--remove-publisher", + default=False, + action=argparse.BooleanOptionalAction, + help="Attempts to remove publisher names from filenames, currently limited to Marvel and DC. Requires --complicated-parser", + ) def comicvine(parser: Manager) -> None: @@ -64,6 +90,7 @@ def comicvine(parser: Manager) -> None: parser.add_setting( "--series-match-search-thresh", default=90, + type=int, ) parser.add_setting("--use-series-start-as-volume", default=False, action=argparse.BooleanOptionalAction) parser.add_setting( @@ -73,7 +100,12 @@ def comicvine(parser: Manager) -> None: dest="clear_metadata_on_import", action=argparse.BooleanOptionalAction, ) - parser.add_setting("--remove-html-tables", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--remove-html-tables", + default=False, + action=argparse.BooleanOptionalAction, + help="Removes html tables instead of converting them to text", + ) parser.add_setting( "--cv-api-key", help="Use the given Comic Vine API Key (persisted in settings).", @@ -87,13 +119,30 @@ def comicvine(parser: Manager) -> None: "--auto-imprint", action=argparse.BooleanOptionalAction, default=False, - help="""Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n""", + help="Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n", ) - parser.add_setting("--sort-series-by-year", default=True, action=argparse.BooleanOptionalAction) - parser.add_setting("--exact-series-matches-first", default=True, action=argparse.BooleanOptionalAction) - parser.add_setting("--always-use-publisher-filter", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--clear-form-before-populating-from-cv", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--sort-series-by-year", default=True, action=argparse.BooleanOptionalAction, help="Sorts series by year" + ) + parser.add_setting( + "--exact-series-matches-first", + default=True, + action=argparse.BooleanOptionalAction, + help="Puts series that are an exact match at the top of the list", + ) + parser.add_setting( + "--always-use-publisher-filter", + default=False, + action=argparse.BooleanOptionalAction, + help="Enables the publisher filter", + ) + parser.add_setting( + "--clear-form-before-populating-from-cv", + default=False, + action=argparse.BooleanOptionalAction, + help="Clears all existing metadata when applying metadata from ComicVine", + ) def cbl(parser: Manager) -> None: @@ -111,13 +160,38 @@ def cbl(parser: Manager) -> None: def rename(parser: Manager) -> None: # Rename settings - parser.add_setting("--template", default="{series} #{issue} ({year})") - parser.add_setting("--issue-number-padding", default=3, type=int) - parser.add_setting("--use-smart-string-cleanup", default=True, action=argparse.BooleanOptionalAction) - parser.add_setting("--set-extension-based-on-archive", default=True, action=argparse.BooleanOptionalAction) - parser.add_setting("--dir", default="") - parser.add_setting("--move-to-dir", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--strict", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--template", default="{series} #{issue} ({year})", help="The teplate to use when renaming") + parser.add_setting( + "--issue-number-padding", + default=3, + type=int, + help="The minimum number of digits to use for the issue number when renaming", + ) + parser.add_setting( + "--use-smart-string-cleanup", + default=True, + action=argparse.BooleanOptionalAction, + help="Attempts to intelligently cleanup whitespace when renaming", + ) + parser.add_setting( + "--set-extension-based-on-archive", + default=True, + action=argparse.BooleanOptionalAction, + help="Automatically sets the extension based on the archive type e.g. cbr for rar, cbz for zip", + ) + parser.add_setting("--dir", default="", help="The directory to move renamed files to") + parser.add_setting( + "--move-to-dir", + default=False, + action=argparse.BooleanOptionalAction, + help="Enables moving renamed files to a separate directory", + ) + parser.add_setting( + "--strict", + default=False, + action=argparse.BooleanOptionalAction, + help="Ensures that filenames are valid for all OSs", + ) parser.add_setting( "replacements", default=DEFAULT_REPLACEMENTS, @@ -127,25 +201,40 @@ def rename(parser: Manager) -> None: def autotag(parser: Manager) -> None: # Auto-tag stickies - parser.add_setting("--save-on-low-confidence", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--dont-use-year-when-identifying", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--save-on-low-confidence", + default=False, + action=argparse.BooleanOptionalAction, + help="Automatically save metadata on low-confidence matches", + ) + parser.add_setting( + "--dont-use-year-when-identifying", + default=False, + action=argparse.BooleanOptionalAction, + help="Ignore the year metadata attribute when identifying a comic", + ) parser.add_setting( "-1", "--assume-issue-one", dest="assume_1_if_no_issue_num", action=argparse.BooleanOptionalAction, - help="""Assume issue number is 1 if not found (relevant for -s).\n\n""", + help="Assume issue number is 1 if not found (relevant for -s).\n\n", default=False, ) - parser.add_setting("--ignore-leading-numbers-in-filename", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--remove-archive-after-successful-match", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting( + "--ignore-leading-numbers-in-filename", + default=False, + action=argparse.BooleanOptionalAction, + help="When searching ignore leading numbers in the filename", + ) + parser.add_setting("remove_archive_after_successful_match", default=False, cmdline=False) parser.add_setting( "-w", "--wait-on-cv-rate-limit", dest="wait_and_retry_on_rate_limit", action=argparse.BooleanOptionalAction, default=True, - help="""When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n""", + help="When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n", ) From 17d865b72f193db2f27b09dc2c3e8449fa4103e6 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sat, 26 Nov 2022 16:47:18 -0800 Subject: [PATCH 07/22] Refactor cli.py into a class --- comictaggerlib/cli.py | 531 +++++++++++++++++++++-------------------- comictaggerlib/main.py | 2 +- 2 files changed, 267 insertions(+), 266 deletions(-) diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index c9bbce9..a51bc01 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -19,7 +19,6 @@ from __future__ import annotations import json import logging import os -import pathlib import sys from datetime import datetime from pprint import pprint @@ -38,221 +37,182 @@ from comictalker.talkerbase import ComicTalker, TalkerError logger = logging.getLogger(__name__) -def actual_issue_data_fetch(issue_id: int, options: settings.OptionValues, talker_api: ComicTalker) -> GenericMetadata: - # now get the particular issue data - try: - ct_md = talker_api.fetch_comic_data(issue_id) - except TalkerError as e: - logger.exception(f"Error retrieving issue details. Save aborted.\n{e}") - return GenericMetadata() +class CLI: + def __init__(self, options: settings.OptionValues, talker_api: ComicTalker): + self.options = options + self.talker_api = talker_api + self.batch_mode = False - if options["cbl"]["apply_cbl_transform_on_cv_import"]: - ct_md = CBLTransformer(ct_md, options).apply() + def actual_issue_data_fetch(self, issue_id: int) -> GenericMetadata: + # now get the particular issue data + try: + ct_md = self.talker_api.fetch_comic_data(issue_id) + except TalkerError as e: + logger.exception(f"Error retrieving issue details. Save aborted.\n{e}") + return GenericMetadata() - return ct_md + if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: + ct_md = CBLTransformer(ct_md, self.options).apply() + return ct_md -def actual_metadata_save(ca: ComicArchive, options: settings.OptionValues, md: GenericMetadata) -> bool: - if not options["runtime"]["dryrun"]: - for metadata_style in options["runtime"]["type"]: - # write out the new data - if not ca.write_metadata(md, metadata_style): - logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style]) - return False + def actual_metadata_save(self, ca: ComicArchive, md: GenericMetadata) -> bool: + if not self.options["runtime"]["dryrun"]: + for metadata_style in self.options["runtime"]["type"]: + # write out the new data + if not ca.write_metadata(md, metadata_style): + logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style]) + return False - print("Save complete.") - logger.info("Save complete.") - else: - if options["runtime"]["terse"]: - logger.info("dry-run option was set, so nothing was written") - print("dry-run option was set, so nothing was written") + print("Save complete.") + logger.info("Save complete.") 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}") - return True - - -def display_match_set_for_choice( - label: str, - match_set: MultipleMatch, - options: settings.OptionValues, - talker_api: ComicTalker, -) -> None: - print(f"{match_set.ca.path} -- {label}:") - - # sort match list by year - match_set.matches.sort(key=lambda k: k["year"] or 0) - - for (counter, m) in enumerate(match_set.matches): - counter += 1 - print( - " {}. {} #{} [{}] ({}/{}) - {}".format( - counter, - m["series"], - m["issue_number"], - m["publisher"], - m["month"], - m["year"], - m["issue_title"], - ) - ) - if options["runtime"]["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": - break - if i != "s": - # save the data! - # we know at this point, that the file is all good to go - ca = match_set.ca - md = create_local_metadata(ca, options) - ct_md = actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"], options, talker_api) - if options["comicvine"]["clear_metadata_on_import"]: - md = ct_md + if self.options["runtime"]["terse"]: + logger.info("dry-run option was set, so nothing was written") + print("dry-run option was set, so nothing was written") else: - notes = ( - f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on" - f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]" + 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}") + return True + + def display_match_set_for_choice(self, label: str, match_set: MultipleMatch) -> None: + print(f"{match_set.ca.path} -- {label}:") + + # sort match list by year + match_set.matches.sort(key=lambda k: k["year"] or 0) + + for (counter, m) in enumerate(match_set.matches): + counter += 1 + print( + " {}. {} #{} [{}] ({}/{}) - {}".format( + counter, + m["series"], + m["issue_number"], + m["publisher"], + m["month"], + m["year"], + m["issue_title"], ) - md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) + ) + if self.options["runtime"]["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": + break + if i != "s": + # save the data! + # we know at this point, that the file is all good to go + ca = match_set.ca + md = self.create_local_metadata(ca) + ct_md = self.actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"]) + if self.options["comicvine"]["clear_metadata_on_import"]: + md = ct_md + else: + notes = ( + f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on" + 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 options["comicvine"]["auto_imprint"]: - md.fix_publisher() + if self.options["comicvine"]["auto_imprint"]: + md.fix_publisher() - actual_metadata_save(ca, options, md) + self.actual_metadata_save(ca, md) + def post_process_matches(self, match_results: OnlineMatchResults) -> None: + # now go through the match results + if self.options["runtime"]["show_save_summary"]: + if len(match_results.good_matches) > 0: + print("\nSuccessful matches:\n------------------") + for f in match_results.good_matches: + print(f) -def post_process_matches( - match_results: OnlineMatchResults, options: settings.OptionValues, talker_api: ComicTalker -) -> None: - # now go through the match results - if options["runtime"]["show_save_summary"]: - if len(match_results.good_matches) > 0: - print("\nSuccessful matches:\n------------------") - for f in match_results.good_matches: - print(f) + if len(match_results.no_matches) > 0: + print("\nNo matches:\n------------------") + for f in match_results.no_matches: + print(f) - if len(match_results.no_matches) > 0: - print("\nNo matches:\n------------------") - for f in match_results.no_matches: - print(f) + if len(match_results.write_failures) > 0: + print("\nFile Write Failures:\n------------------") + for f in match_results.write_failures: + print(f) - if len(match_results.write_failures) > 0: - print("\nFile Write Failures:\n------------------") - for f in match_results.write_failures: - print(f) + if len(match_results.fetch_data_failures) > 0: + print("\nNetwork Data Fetch Failures:\n------------------") + for f in match_results.fetch_data_failures: + print(f) - if len(match_results.fetch_data_failures) > 0: - print("\nNetwork Data Fetch Failures:\n------------------") - for f in match_results.fetch_data_failures: - print(f) + if not self.options["runtime"]["show_save_summary"] and not self.options["runtime"]["interactive"]: + # just quit if we're not interactive or showing the summary + return - if not options["runtime"]["show_save_summary"] and not options["runtime"]["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------------------") + for match_set in match_results.multiple_matches: + self.display_match_set_for_choice("Multiple high-confidence matches", match_set) - if len(match_results.multiple_matches) > 0: - print("\nArchives with multiple high-confidence matches:\n------------------") - for match_set in match_results.multiple_matches: - display_match_set_for_choice("Multiple high-confidence matches", match_set, options, talker_api) + if len(match_results.low_confidence_matches) > 0: + print("\nArchives with low-confidence matches:\n------------------") + for match_set in match_results.low_confidence_matches: + if len(match_set.matches) == 1: + label = "Single low-confidence match" + else: + label = "Multiple low-confidence matches" - if len(match_results.low_confidence_matches) > 0: - print("\nArchives with low-confidence matches:\n------------------") - for match_set in match_results.low_confidence_matches: - if len(match_set.matches) == 1: - label = "Single low-confidence match" - else: - label = "Multiple low-confidence matches" + self.display_match_set_for_choice(label, match_set) - display_match_set_for_choice(label, match_set, options, talker_api) + def run(self) -> None: + if len(self.options["runtime"]["file_list"]) < 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.options["runtime"]["file_list"]) > 1 -def cli_mode(options: settings.OptionValues, talker_api: ComicTalker) -> None: - if len(options["runtime"]["file_list"]) < 1: - logger.error("You must specify at least one filename. Use the -h option for more info") - return + for f in self.options["runtime"]["file_list"]: + self.process_file_cli(f, match_results) + sys.stdout.flush() - match_results = OnlineMatchResults() + self.post_process_matches(match_results) - for f in options["runtime"]["file_list"]: - process_file_cli(f, options, talker_api, match_results) - sys.stdout.flush() + def create_local_metadata(self, ca: ComicArchive) -> GenericMetadata: + md = GenericMetadata() + md.set_default_page_list(ca.get_number_of_pages()) - post_process_matches(match_results, options, talker_api) + # now, overlay the parsed filename info + if self.options["runtime"]["parse_filename"]: + f_md = ca.metadata_from_filename( + self.options["filename"]["complicated_parser"], + self.options["filename"]["remove_c2c"], + self.options["filename"]["remove_fcbd"], + self.options["filename"]["remove_publisher"], + self.options["runtime"]["split_words"], + ) + md.overlay(f_md) -def create_local_metadata(ca: ComicArchive, options: settings.OptionValues) -> GenericMetadata: - md = GenericMetadata() - md.set_default_page_list(ca.get_number_of_pages()) + for metadata_style in self.options["runtime"]["type"]: + if ca.has_metadata(metadata_style): + try: + t_md = ca.read_metadata(metadata_style) + md.overlay(t_md) + break + except Exception as e: + logger.error("Failed to load metadata for %s: %s", ca.path, e) - # now, overlay the parsed filename info - if options["runtime"]["parse_filename"]: - f_md = ca.metadata_from_filename( - options["filename"]["complicated_parser"], - options["filename"]["remove_c2c"], - options["filename"]["remove_fcbd"], - options["filename"]["remove_publisher"], - options["runtime"]["split_words"], - ) + # finally, use explicit stuff + md.overlay(self.options["runtime"]["metadata"]) - md.overlay(f_md) + return md - for metadata_style in options["runtime"]["type"]: - if ca.has_metadata(metadata_style): - try: - t_md = ca.read_metadata(metadata_style) - md.overlay(t_md) - break - except Exception as e: - logger.error("Failed to load metadata for %s: %s", ca.path, e) - - # finally, use explicit stuff - md.overlay(options["runtime"]["metadata"]) - - return md - - -def process_file_cli( - filename: str, options: settings.OptionValues, talker_api: ComicTalker, match_results: OnlineMatchResults -) -> None: - batch_mode = len(options["runtime"]["file_list"]) > 1 - - ca = ComicArchive(filename, options["general"]["rar_exe_path"], str(graphics_path / "nocover.png")) - - if not os.path.lexists(filename): - logger.error("Cannot find %s", filename) - return - - if not ca.seems_to_be_a_comic_archive(): - logger.error("Sorry, but %s is not a comic archive!", filename) - return - - if not ca.is_writable() and ( - options["commands"]["delete"] - or options["commands"]["copy"] - or options["commands"]["save"] - or options["commands"]["rename"] - ): - logger.error("This archive is not writable") - return - - has = [False, False, False] - if ca.has_cix(): - has[MetaDataStyle.CIX] = True - if ca.has_cbi(): - has[MetaDataStyle.CBI] = True - if ca.has_comet(): - has[MetaDataStyle.COMET] = True - - if options["commands"]["print"]: - - if not options["runtime"]["type"]: + def print(self, ca: ComicArchive) -> None: + if not self.options["runtime"]["type"]: page_count = ca.get_number_of_pages() brief = "" - if batch_mode: + if self.batch_mode: brief = f"{ca.path}: " if ca.is_sevenzip(): @@ -267,146 +227,149 @@ def process_file_cli( brief += f"({page_count: >3} pages)" brief += " tags:[ " - if not (has[MetaDataStyle.CBI] or has[MetaDataStyle.CIX] or has[MetaDataStyle.COMET]): + if not ( + ca.has_metadata(MetaDataStyle.CBI) + or ca.has_metadata(MetaDataStyle.CIX) + or ca.has_metadata(MetaDataStyle.COMET) + ): brief += "none " else: - if has[MetaDataStyle.CBI]: + if ca.has_metadata(MetaDataStyle.CBI): brief += "CBL " - if has[MetaDataStyle.CIX]: + if ca.has_metadata(MetaDataStyle.CIX): brief += "CR " - if has[MetaDataStyle.COMET]: + if ca.has_metadata(MetaDataStyle.COMET): brief += "CoMet " brief += "]" print(brief) - if options["runtime"]["terse"]: + if self.options["runtime"]["terse"]: return print() - if not options["runtime"]["type"] or MetaDataStyle.CIX in options["runtime"]["type"]: - if has[MetaDataStyle.CIX]: + if not self.options["runtime"]["type"] or MetaDataStyle.CIX in self.options["runtime"]["type"]: + if ca.has_metadata(MetaDataStyle.CIX): print("--------- ComicRack tags ---------") try: - if options["runtime"]["raw"]: + if self.options["runtime"]["raw"]: print(ca.read_raw_cix()) else: print(ca.read_cix()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - if not options["runtime"]["type"] or MetaDataStyle.CBI in options["runtime"]["type"]: - if has[MetaDataStyle.CBI]: + if not self.options["runtime"]["type"] or MetaDataStyle.CBI in self.options["runtime"]["type"]: + if ca.has_metadata(MetaDataStyle.CBI): print("------- ComicBookLover tags -------") try: - if options["runtime"]["raw"]: + if self.options["runtime"]["raw"]: pprint(json.loads(ca.read_raw_cbi())) else: print(ca.read_cbi()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - if not options["runtime"]["type"] or MetaDataStyle.COMET in options["runtime"]["type"]: - if has[MetaDataStyle.COMET]: + if not self.options["runtime"]["type"] or MetaDataStyle.COMET in self.options["runtime"]["type"]: + if ca.has_metadata(MetaDataStyle.COMET): print("----------- CoMet tags -----------") try: - if options["runtime"]["raw"]: + if self.options["runtime"]["raw"]: print(ca.read_raw_comet()) else: print(ca.read_comet()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - elif options["commands"]["delete"]: - for metadata_style in options["runtime"]["type"]: + def delete(self, ca: ComicArchive) -> None: + for metadata_style in self.options["runtime"]["type"]: style_name = MetaDataStyle.name[metadata_style] - if has[metadata_style]: - if not options["runtime"]["dryrun"]: + if ca.has_metadata(metadata_style): + if not self.options["runtime"]["dryrun"]: if not ca.remove_metadata(metadata_style): - print(f"{filename}: Tag removal seemed to fail!") + print(f"{ca.path}: Tag removal seemed to fail!") else: - print(f"{filename}: Removed {style_name} tags.") + print(f"{ca.path}: Removed {style_name} tags.") else: - print(f"{filename}: dry-run. {style_name} tags not removed") + print(f"{ca.path}: dry-run. {style_name} tags not removed") else: - print(f"{filename}: This archive doesn't have {style_name} tags to remove.") + print(f"{ca.path}: This archive doesn't have {style_name} tags to remove.") - elif options["commands"]["copy"] is not None: - for metadata_style in options["runtime"]["type"]: + def copy(self, ca: ComicArchive) -> None: + for metadata_style in self.options["runtime"]["type"]: dst_style_name = MetaDataStyle.name[metadata_style] - if options["runtime"]["no_overwrite"] and has[metadata_style]: - print(f"{filename}: Already has {dst_style_name} tags. Not overwriting.") + if self.options["runtime"]["no_overwrite"] and ca.has_metadata(metadata_style): + print(f"{ca.path}: Already has {dst_style_name} tags. Not overwriting.") return - if options["commands"]["copy"] == metadata_style: - print(f"{filename}: Destination and source are same: {dst_style_name}. Nothing to do.") + if self.options["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[options["commands"]["copy"]] - if has[options["commands"]["copy"]]: - if not options["runtime"]["dryrun"]: + src_style_name = MetaDataStyle.name[self.options["commands"]["copy"]] + if ca.has_metadata(self.options["commands"]["copy"]): + if not self.options["runtime"]["dryrun"]: try: - md = ca.read_metadata(options["commands"]["copy"]) + md = ca.read_metadata(self.options["commands"]["copy"]) except Exception as e: md = GenericMetadata() logger.error("Failed to load metadata for %s: %s", ca.path, e) - if options["apply_cbl_transform_on_bulk_operation"] and metadata_style == MetaDataStyle.CBI: - md = CBLTransformer(md, options).apply() + if self.options["apply_cbl_transform_on_bulk_operation"] and metadata_style == MetaDataStyle.CBI: + md = CBLTransformer(md, self.options).apply() if not ca.write_metadata(md, metadata_style): - print(f"{filename}: Tag copy seemed to fail!") + print(f"{ca.path}: Tag copy seemed to fail!") else: - print(f"{filename}: Copied {src_style_name} tags to {dst_style_name}.") + print(f"{ca.path}: Copied {src_style_name} tags to {dst_style_name}.") else: - print(f"{filename}: dry-run. {src_style_name} tags not copied") + print(f"{ca.path}: dry-run. {src_style_name} tags not copied") else: - print(f"{filename}: This archive doesn't have {src_style_name} tags to copy.") + print(f"{ca.path}: This archive doesn't have {src_style_name} tags to copy.") - elif options["commands"]["save"]: - - if options["runtime"]["no_overwrite"]: - for metadata_style in options["runtime"]["type"]: - if has[metadata_style]: - print(f"{filename}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.") + def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> None: + if self.options["runtime"]["no_overwrite"]: + for metadata_style in self.options["runtime"]["type"]: + if ca.has_metadata(metadata_style): + print(f"{ca.path}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.") return - if batch_mode: + if self.batch_mode: print(f"Processing {ca.path}...") - md = create_local_metadata(ca, options) + md = self.create_local_metadata(ca) if md.issue is None or md.issue == "": - if options["runtime"]["assume_issue_one"]: + if self.options["runtime"]["assume_issue_one"]: md.issue = "1" # now, search online - if options["runtime"]["online"]: - if options["runtime"]["issue_id"] is not None: + if self.options["runtime"]["online"]: + if self.options["runtime"]["issue_id"] is not None: # we were given the actual issue ID to search with try: - ct_md = talker_api.fetch_comic_data(options["runtime"]["issue_id"]) + ct_md = self.talker_api.fetch_comic_data(self.options["runtime"]["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 if ct_md is None: - logger.error("No match for ID %s was found.", options["runtime"]["issue_id"]) + logger.error("No match for ID %s was found.", self.options["runtime"]["issue_id"]) match_results.no_matches.append(str(ca.path.absolute())) return - if options["cbl"]["apply_cbl_transform_on_cv_import"]: - ct_md = CBLTransformer(ct_md, options).apply() + if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: + ct_md = CBLTransformer(ct_md, self.options).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 - ii = IssueIdentifier(ca, options, talker_api) + ii = IssueIdentifier(ca, self.options, self.talker_api) def myoutput(text: str) -> None: - if options["runtime"]["verbose"]: + if self.options["runtime"]["verbose"]: IssueIdentifier.default_write_output(text) # use our overlaid MD struct to search @@ -446,7 +409,7 @@ def process_file_cli( logger.error("Online search: Multiple good matches. Save aborted") match_results.multiple_matches.append(MultipleMatch(ca, matches)) return - if low_confidence and options["runtime"]["abort_on_low_confidence"]: + if low_confidence and self.options["runtime"]["abort_on_low_confidence"]: logger.error("Online search: Low confidence match. Save aborted") match_results.low_confidence_matches.append(MultipleMatch(ca, matches)) return @@ -458,12 +421,12 @@ def process_file_cli( # we got here, so we have a single match # now get the particular issue data - ct_md = actual_issue_data_fetch(matches[0]["issue_id"], options, talker_api) + 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 - if options["comicvine"]["clear_metadata_on_import"]: + if self.options["comicvine"]["clear_metadata_on_import"]: md = ct_md else: notes = ( @@ -472,29 +435,29 @@ def process_file_cli( ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if options["comicvine"]["auto_imprint"]: + if self.options["comicvine"]["auto_imprint"]: md.fix_publisher() # ok, done building our metadata. time to save - if not actual_metadata_save(ca, options, md): + if not self.actual_metadata_save(ca, md): match_results.write_failures.append(str(ca.path.absolute())) else: match_results.good_matches.append(str(ca.path.absolute())) - elif options["commands"]["rename"]: + def rename(self, ca: ComicArchive) -> None: original_path = ca.path msg_hdr = "" - if batch_mode: + if self.batch_mode: msg_hdr = f"{ca.path}: " - md = create_local_metadata(ca, options) + md = self.create_local_metadata(ca) if md.series is None: logger.error(msg_hdr + "Can't rename without series name") return new_ext = "" # default - if options["filename"]["rename_set_extension_based_on_archive"]: + if self.options["filename"]["rename_set_extension_based_on_archive"]: if ca.is_sevenzip(): new_ext = ".cb7" elif ca.is_zip(): @@ -504,13 +467,13 @@ def process_file_cli( renamer = FileRenamer( md, - platform="universal" if options[filename]["rename_strict"] else "auto", - replacements=options["rename"]["replacements"], + platform="universal" if self.options["filename"]["rename_strict"] else "auto", + replacements=self.options["rename"]["replacements"], ) - renamer.set_template(options[filename]["rename_template"]) - renamer.set_issue_zero_padding(options[filename]["rename_issue_number_padding"]) - renamer.set_smart_cleanup(options[filename]["rename_use_smart_string_cleanup"]) - renamer.move = options[filename]["rename_move_to_dir"] + renamer.set_template(self.options["filename"]["rename_template"]) + renamer.set_issue_zero_padding(self.options["filename"]["rename_issue_number_padding"]) + renamer.set_smart_cleanup(self.options["filename"]["rename_use_smart_string_cleanup"]) + renamer.move = self.options["filename"]["rename_move_to_dir"] try: new_name = renamer.determine_name(ext=new_ext) @@ -522,16 +485,16 @@ def process_file_cli( "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", - options[filename]["rename_template"], + self.options["filename"]["rename_template"], ) return except Exception: logger.exception( - "Formatter failure: %s metadata: %s", options[filename]["rename_template"], renamer.metadata + "Formatter failure: %s metadata: %s", self.options["filename"]["rename_template"], renamer.metadata ) folder = get_rename_dir( - ca, options[filename]["rename_dir"] if options[filename]["rename_move_to_dir"] else None + ca, self.options["filename"]["rename_dir"] if self.options["filename"]["rename_move_to_dir"] else None ) full_path = folder / new_name @@ -541,7 +504,7 @@ def process_file_cli( return suffix = "" - if not options["runtime"]["dryrun"]: + if not self.options["runtime"]["dryrun"]: # rename the file try: ca.rename(utils.unique_file(full_path)) @@ -552,19 +515,19 @@ def process_file_cli( print(f"renamed '{original_path.name}' -> '{new_name}' {suffix}") - elif options["commands"]["export_to_zip"]: + def export(self, ca: ComicArchive) -> None: msg_hdr = "" - if batch_mode: + if self.batch_mode: msg_hdr = f"{ca.path}: " if ca.is_zip(): logger.error(msg_hdr + "Archive is already a zip file.") return - filename_path = pathlib.Path(filename).absolute() + filename_path = ca.path new_file = filename_path.with_suffix(".cbz") - if options["runtime"]["abort_on_conflict"] and new_file.exists(): + if self.options["runtime"]["abort_on_conflict"] and new_file.exists(): print(msg_hdr + f"{new_file.name} already exists in the that folder.") return @@ -572,10 +535,10 @@ def process_file_cli( delete_success = False export_success = False - if not options["runtime"]["dryrun"]: + if not self.options["runtime"]["dryrun"]: if ca.export_as_zip(new_file): export_success = True - if options["runtime"]["delete_after_zip_export"]: + if self.options["runtime"]["delete_after_zip_export"]: try: filename_path.unlink(missing_ok=True) delete_success = True @@ -587,7 +550,7 @@ def process_file_cli( new_file.unlink(missing_ok=True) else: msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}" - if options["runtime"]["delete_after_zip_export"]: + if self.options["runtime"]["delete_after_zip_export"]: msg += " and delete original." print(msg) return @@ -595,9 +558,47 @@ def process_file_cli( msg = msg_hdr if export_success: msg += f"Archive exported successfully to: {os.path.split(new_file)[1]}" - if options["runtime"]["delete_after_zip_export"] and delete_success: + if self.options["runtime"]["delete_after_zip_export"] and delete_success: msg += " (Original deleted) " else: msg += "Archive failed to export!" print(msg) + + def process_file_cli(self, filename: str, match_results: OnlineMatchResults) -> None: + if not os.path.lexists(filename): + logger.error("Cannot find %s", filename) + return + + ca = ComicArchive(filename, self.options["general"]["rar_exe_path"], 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 + + if not ca.is_writable() and ( + self.options["commands"]["delete"] + or self.options["commands"]["copy"] + or self.options["commands"]["save"] + or self.options["commands"]["rename"] + ): + logger.error("This archive is not writable") + return + + if self.options["commands"]["print"]: + self.print(ca) + + elif self.options["commands"]["delete"]: + self.delete(ca) + + elif self.options["commands"]["copy"] is not None: + self.copy(ca) + + elif self.options["commands"]["save"]: + self.save(ca, match_results) + + elif self.options["commands"]["rename"]: + self.rename(ca) + + elif self.options["commands"]["export_to_zip"]: + self.export(ca) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 0081d4a..244d0da 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -155,7 +155,7 @@ class App: if self.options["runtime"]["no_gui"]: try: - cli.cli_mode(self.options, talker_api) + cli.CLI(self.options, talker_api).run() except Exception: logger.exception("CLI mode failed") else: From 3f6f8540c4b05cbc64b36c575d0c25d16e8872f7 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sat, 26 Nov 2022 16:47:40 -0800 Subject: [PATCH 08/22] Fix wait_and_retry_on_rate_limit --- comictaggerlib/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 244d0da..978747b 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -143,7 +143,7 @@ class App: series_match_thresh=self.options["comicvine"]["series_match_search_thresh"], remove_html_tables=self.options["comicvine"]["remove_html_tables"], use_series_start_as_volume=self.options["comicvine"]["use_series_start_as_volume"], - wait_on_ratelimit=self.options["comicvine"]["wait_and_retry_on_rate_limit"], + wait_on_ratelimit=self.options["autotag"]["wait_and_retry_on_rate_limit"], api_url=self.options["comicvine"]["cv_url"], api_key=self.options["comicvine"]["cv_api_key"], ) From 460a5bc4f4e78ea7128d52a21fb9c9c4036ec806 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sun, 27 Nov 2022 11:17:58 -0800 Subject: [PATCH 09/22] Cleanup --- comictaggerlib/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comictaggerlib/log.py b/comictaggerlib/log.py index e022f67..04f510f 100644 --- a/comictaggerlib/log.py +++ b/comictaggerlib/log.py @@ -19,7 +19,7 @@ def setup_logging(verbose: int, log_dir: pathlib.Path) -> None: logging.getLogger("comictaggerlib").setLevel(logging.DEBUG) log_file = log_dir / "ComicTagger.log" - log_file.parent.mkdir(parents=True, exist_ok=True) + log_dir.mkdir(parents=True, exist_ok=True) stream_handler = logging.StreamHandler() file_handler = get_file_handler(log_file) From af0d7b878bd189f480439c1257206ca1b5e0a176 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sun, 27 Nov 2022 11:36:26 -0800 Subject: [PATCH 10/22] Set logging level on comictalker --- comictaggerlib/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/comictaggerlib/log.py b/comictaggerlib/log.py index 04f510f..7910309 100644 --- a/comictaggerlib/log.py +++ b/comictaggerlib/log.py @@ -17,6 +17,7 @@ def get_file_handler(filename: pathlib.Path) -> logging.FileHandler: def setup_logging(verbose: int, log_dir: pathlib.Path) -> None: logging.getLogger("comicapi").setLevel(logging.DEBUG) logging.getLogger("comictaggerlib").setLevel(logging.DEBUG) + logging.getLogger("comictalker").setLevel(logging.DEBUG) log_file = log_dir / "ComicTagger.log" log_dir.mkdir(parents=True, exist_ok=True) From 028949f216afe833fbb28df7be2fd690c8d929e7 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Tue, 29 Nov 2022 15:14:53 -0800 Subject: [PATCH 11/22] Make logs use the .log extension --- comictaggerlib/log.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/comictaggerlib/log.py b/comictaggerlib/log.py index 7910309..db8b929 100644 --- a/comictaggerlib/log.py +++ b/comictaggerlib/log.py @@ -6,8 +6,14 @@ import pathlib logger = logging.getLogger("comictagger") +def get_filename(filename: str) -> str: + filename, _, number = filename.rpartition(".") + return filename.removesuffix("log") + number + ".log" + + def get_file_handler(filename: pathlib.Path) -> logging.FileHandler: file_handler = logging.handlers.RotatingFileHandler(filename, encoding="utf-8", backupCount=10) + file_handler.namer = get_filename if filename.is_file() and filename.stat().st_size > 0: file_handler.doRollover() From 0302511f5fa0af841ff75d2d6e8aca82afc3ee3b Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Tue, 29 Nov 2022 17:19:08 -0800 Subject: [PATCH 12/22] Settings tests --- comictaggerlib/settings/manager.py | 10 ++--- tests/conftest.py | 15 +++++--- tests/settings_test.py | 59 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 tests/settings_test.py diff --git a/comictaggerlib/settings/manager.py b/comictaggerlib/settings/manager.py index 5dfb1e9..89d9e0f 100644 --- a/comictaggerlib/settings/manager.py +++ b/comictaggerlib/settings/manager.py @@ -248,13 +248,9 @@ If no options are given, %(prog)s will run in windowed mode.""", file_options = self.parse_file(config_paths.user_config_dir / "settings.json") cli_options = self.parse_args(args, namespace=self.get_namespace_for_args(file_options)) - # for group, group_options in cli_options.items(): - # if group in cli_options: - # file_options[group].update(group_options) - # else: - # file_options[group] = group_options - # Just in case something weird happens with the commandline options - file_options["runtime"]["config"] = config_paths + if "runtime" in cli_options: + # Just in case something weird happens with the commandline options + cli_options["runtime"]["config"] = config_paths # Normalize a final time for fun return self.normalize_options(cli_options, file=True, cmdline=True) diff --git a/tests/conftest.py b/tests/conftest.py index a15df4a..bde5491 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -163,12 +163,11 @@ def seed_all_publishers(monkeypatch): @pytest.fixture -def settings(tmp_path): +def settings(settings_manager, tmp_path): - manager = ctsettings.Manager() - ctsettings.register_commandline(manager) - ctsettings.register_settings(manager) - defaults = manager.defaults() + ctsettings.register_commandline(settings_manager) + ctsettings.register_settings(settings_manager) + defaults = settings_manager.defaults() defaults["runtime"]["config"] = ctsettings.ComicTaggerPaths(tmp_path / "config") defaults["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) defaults["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) @@ -178,6 +177,12 @@ def settings(tmp_path): yield defaults +@pytest.fixture +def settings_manager(): + manager = ctsettings.Manager() + yield manager + + @pytest.fixture def comic_cache(settings, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]: yield comictalker.comiccacher.ComicCacher(settings["runtime"]["config"].user_cache_dir, mock_version[0]) diff --git a/tests/settings_test.py b/tests/settings_test.py new file mode 100644 index 0000000..74fcb38 --- /dev/null +++ b/tests/settings_test.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from comictaggerlib import settings as ctsettings + + +def test_settings_manager(): + manager = ctsettings.Manager() + defaults = manager.defaults() + assert manager is not None and defaults is not None + + +settings_cases = [ + ( + { + "test": ( + { + "args": ["--test"], + "kwargs": dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + ), + }, + ), + }, + dict({"test": {"test": {"names": ["--test"], "dest": "test", "default": None}}}), + ) +] + + +@pytest.mark.parametrize("settings, expected", settings_cases) +def test_add_setting(settings, expected, settings_manager, tmp_path): + for group, settings in settings.items(): + + def add_settings(parser): + for setting in settings: + settings_manager.add_setting(*setting["args"], **setting["kwargs"]) + + settings_manager.add_group(group, add_settings) + + parsed_settings = settings_manager.parse_options(ctsettings.ComicTaggerPaths(tmp_path), args=[]) + + # print(parsed_settings) + # print(expected) + for group, settings in expected.items(): + for setting_name, setting in settings.items(): + assert parsed_settings[group][setting_name] == setting["default"] + assert False From 9aff3ae38ee77a40107d465af9df56bd2486e5dc Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Mon, 5 Dec 2022 18:58:28 -0800 Subject: [PATCH 13/22] Generalize settings Add comments and docstrings Create parent directories when saving Add merging to normalize_options Change get_option to return if the value is the default value --- .pre-commit-config.yaml | 2 +- comictaggerlib/main.py | 7 +- comictaggerlib/settings/__init__.py | 5 +- comictaggerlib/settings/manager.py | 150 ++++++++++++++++++---------- comictaggerlib/settings/types.py | 2 - comictaggerlib/settingswindow.py | 2 +- 6 files changed, 107 insertions(+), 61 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3992ce..fea6f61 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: rev: v1.7.7 hooks: - id: autoflake - args: [-i] + args: [-i, --remove-all-unused-imports, --ignore-init-module-imports] - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 978747b..01a62c3 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -81,12 +81,15 @@ class App: return opts def register_options(self) -> None: - self.manager = settings.Manager() + self.manager = settings.Manager( + """A utility for reading and writing metadata to comic archives.\n\n\nIf no options are given, %(prog)s will run in windowed mode.""", + "For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki", + ) settings.register_commandline(self.manager) settings.register_settings(self.manager) def parse_options(self, config_paths: settings.ComicTaggerPaths) -> None: - options = self.manager.parse_options(config_paths) + options = self.manager.parse_options(config_paths.user_config_dir / "settings.json") self.options = settings.validate_commandline_options(options, self.manager) self.options = settings.validate_settings(options, self.manager) diff --git a/comictaggerlib/settings/__init__.py b/comictaggerlib/settings/__init__.py index 5219e80..a7c069b 100644 --- a/comictaggerlib/settings/__init__.py +++ b/comictaggerlib/settings/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations from comictaggerlib.settings.cmdline import initial_cmd_line_parser, register_commandline, validate_commandline_options from comictaggerlib.settings.file import register_settings, validate_settings -from comictaggerlib.settings.manager import Manager -from comictaggerlib.settings.types import ComicTaggerPaths, OptionValues +from comictaggerlib.settings.manager import Manager, OptionDefinitions, OptionValues +from comictaggerlib.settings.types import ComicTaggerPaths __all__ = [ "initial_cmd_line_parser", @@ -14,4 +14,5 @@ __all__ = [ "Manager", "ComicTaggerPaths", "OptionValues", + "OptionDefinitions", ] diff --git a/comictaggerlib/settings/manager.py b/comictaggerlib/settings/manager.py index 89d9e0f..28fab66 100644 --- a/comictaggerlib/settings/manager.py +++ b/comictaggerlib/settings/manager.py @@ -8,8 +8,6 @@ from collections import defaultdict from collections.abc import Sequence from typing import Any, Callable, NoReturn, Union -from comictaggerlib.settings.types import ComicTaggerPaths, OptionValues - logger = logging.getLogger(__name__) @@ -36,13 +34,20 @@ class Setting: ): if not names: raise ValueError("names must be specified") + # We prefix the destination name used by argparse so that there are no conflicts + # Argument names will still cause an exception if there is a conflict e.g. if '-f' is defined twice self.internal_name, dest, flag = self.get_dest(group, names, dest) args: Sequence[str] = names + + # We then also set the metavar so that '--config' in the group runtime shows as 'CONFIG' instead of 'RUNTIME_CONFIG' if not metavar and action not in ("store_true", "store_false", "count"): metavar = dest.upper() + # If we are not a flag, no '--' or '-' in front + # we prefix the first name with the group as argparse sets dest to args[0] + # I believe internal name may be able to be used here if not flag: - args = [f"{group}_{names[0]}".lstrip("_"), *names[1:]] + args = tuple((f"{group}_{names[0]}".lstrip("_"), *names[1:])) self.action = action self.nargs = nargs @@ -108,53 +113,68 @@ class Setting: return self.argparse_args, self.filter_argparse_kwargs() +OptionValues = dict[str, dict[str, Any]] +OptionDefinitions = dict[str, dict[str, Setting]] ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] class Manager: """docstring for SettingManager""" - def __init__(self, options: dict[str, dict[str, Setting]] | None = None): - self.argparser = argparse.ArgumentParser() + def __init__( + self, description: str | None = None, epilog: str | None = None, definitions: OptionDefinitions | None = None + ): + # This one is never used, it just makes MyPy happy + self.argparser = argparse.ArgumentParser(description=description, epilog=epilog) + self.description = description + self.epilog = epilog - self.options: dict[str, dict[str, Setting]] = defaultdict(lambda: dict()) - if options: - self.options = options + self.option_definitions: OptionDefinitions = defaultdict(lambda: dict()) + if definitions: + self.option_definitions = definitions - self.current_group: ArgParser | None = None + self.exclusive_group = False self.current_group_name = "" def defaults(self) -> OptionValues: return self.normalize_options({}, file=True, cmdline=True) - def get_namespace_for_args(self, options: OptionValues) -> argparse.Namespace: + def get_namespace(self, options: OptionValues) -> argparse.Namespace: + """ + Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" + `options` should already be normalized. + Throws an exception if the internal_name is duplicated + """ namespace = argparse.Namespace() - for group_name, group in self.options.items(): + for group_name, group in self.option_definitions.items(): for setting_name, setting in group.items(): - if not setting.cmdline: - if hasattr(namespace, setting.internal_name): - raise Exception(f"Duplicate internal name: {setting.internal_name}") - setattr( - namespace, setting.internal_name, options.get(group_name, {}).get(setting.dest, setting.default) - ) + if hasattr(namespace, setting.internal_name): + raise Exception(f"Duplicate internal name: {setting.internal_name}") + setattr( + namespace, + setting.internal_name, + options.get(group_name, {}).get( + setting.dest, + setting.default, + ), + ) + setattr(namespace, "option_definitions", options.get("option_definitions")) return namespace def add_setting(self, *args: Any, **kwargs: Any) -> None: - exclusive = isinstance(self.current_group, argparse._MutuallyExclusiveGroup) - setting = Setting(*args, group=self.current_group_name, exclusive=exclusive, **kwargs) - self.options[self.current_group_name][setting.dest] = setting + """Takes passes all arguments through to `Setting`, `group` and `exclusive` are already set""" + setting = Setting(*args, group=self.current_group_name, exclusive=self.exclusive_group, **kwargs) + self.option_definitions[self.current_group_name][setting.dest] = setting def create_argparser(self) -> None: + """Creates an argparser object from all cmdline settings""" groups: dict[str, ArgParser] = {} self.argparser = argparse.ArgumentParser( - description="""A utility for reading and writing metadata to comic archives. - - -If no options are given, %(prog)s will run in windowed mode.""", - epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki", + description=self.description, + epilog=self.epilog, formatter_class=argparse.RawTextHelpFormatter, ) - for group_name, group in self.options.items(): + for group_name, group in self.option_definitions.items(): for setting_name, setting in group.items(): if setting.cmdline: argparse_args, argparse_kwargs = setting.to_argparse() @@ -163,42 +183,46 @@ If no options are given, %(prog)s will run in windowed mode.""", if setting.group not in groups: if setting.exclusive: groups[setting.group] = self.argparser.add_argument_group( - setting.group + setting.group, ).add_mutually_exclusive_group() else: groups[setting.group] = self.argparser.add_argument_group(setting.group) - # hardcoded exception for files + # hard coded exception for files if not (setting.group == "runtime" and setting.nargs == "*"): current_group = groups[setting.group] current_group.add_argument(*argparse_args, **argparse_kwargs) def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None: self.current_group_name = name - if exclusive_group: - self.current_group = self.argparser.add_mutually_exclusive_group() + self.exclusive_group = exclusive_group add_settings(self) self.current_group_name = "" - self.current_group = None + self.exclusive_group = False def exit(self, *args: Any, **kwargs: Any) -> NoReturn: + """Same as `argparser.ArgParser.exit`""" self.argparser.exit(*args, **kwargs) raise SystemExit(99) - def save_file(self, options: OptionValues, filename: pathlib.Path) -> bool: - self.options = options["option_definitions"] + def save_file(self, options: OptionValues | argparse.Namespace, filename: pathlib.Path) -> bool: + if isinstance(options, dict): + self.option_definitions = options["option_definitions"] + elif isinstance(options, argparse.Namespace): + self.option_definitions = options.option_definitions + file_options = self.normalize_options(options, file=True) del file_options["option_definitions"] for group in list(file_options.keys()): if not file_options[group]: del file_options[group] if not filename.exists(): + filename.parent.mkdir(exist_ok=True, parents=True) filename.touch() try: json_str = json.dumps(file_options, indent=2) - with filename.open(mode="w") as file: - file.write(json_str) + filename.write_text(json_str, encoding="utf-8") except Exception: logger.exception("Failed to save config file: %s", filename) return False @@ -220,37 +244,57 @@ If no options are given, %(prog)s will run in windowed mode.""", return self.normalize_options(options, file=True) def normalize_options( - self, raw_options: OptionValues | argparse.Namespace, file: bool = False, cmdline: bool = False + self, + raw_options: OptionValues | argparse.Namespace, + file: bool = False, + cmdline: bool = False, + raw_options_2: OptionValues | argparse.Namespace | None = None, ) -> OptionValues: + """ + Creates an `OptionValues` dictionary with setting definitions taken from `self.option_definitions` + and values taken from `raw_options` and `raw_options_2' if defined. + Values are assigned so if the value is a dictionary mutating it will mutate the original. + """ options: OptionValues = {} - for group_name, group in self.options.items(): + for group_name, group in self.option_definitions.items(): group_options = {} for setting_name, setting in group.items(): if (setting.cmdline and cmdline) or (setting.file and file): - group_options[setting_name] = self.get_option(raw_options, setting, group_name) + # Ensures the option exists with the default if not already set + group_options[setting_name], _ = self.get_option(raw_options, setting, group_name) + + # will override with option from raw_options_2 if it exists + if raw_options_2 is not None: + value, default = self.get_option(raw_options_2, setting, group_name) + if not default: + group_options[setting_name] = value options[group_name] = group_options - options["option_definitions"] = self.options + options["option_definitions"] = self.option_definitions return options - def get_option(self, options: OptionValues | argparse.Namespace, setting: Setting, group_name: str) -> Any: + def get_option( + self, options: OptionValues | argparse.Namespace, setting: Setting, group_name: str + ) -> tuple[Any, bool]: + """Helper function to retrieve the the value for a setting and if the value is the default value""" if isinstance(options, dict): - return options.get(group_name, {}).get(setting.dest, setting.default) - return getattr(options, setting.internal_name) + value = options.get(group_name, {}).get(setting.dest, setting.default) + else: + value = getattr(options, setting.internal_name, setting.default) + return value, value == setting.default def parse_args(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> OptionValues: - """Must handle a namespace with both argparse values and file values""" + """ + Creates an `argparse.ArgumentParser` from cmdline settings in `self.option_definitions`. + `args` and `namespace` are passed to `argparse.ArgumentParser.parse_args` + """ self.create_argparser() ns = self.argparser.parse_args(args, namespace=namespace) - return self.normalize_options(ns, cmdline=True, file=True) + return self.normalize_options(ns, cmdline=True, file=False) - def parse_options(self, config_paths: ComicTaggerPaths, args: list[str] | None = None) -> OptionValues: - file_options = self.parse_file(config_paths.user_config_dir / "settings.json") - cli_options = self.parse_args(args, namespace=self.get_namespace_for_args(file_options)) + def parse_options(self, config_path: pathlib.Path, args: list[str] | None = None) -> OptionValues: + file_options = self.parse_file(config_path) + cli_options = self.parse_args(args) - if "runtime" in cli_options: - # Just in case something weird happens with the commandline options - cli_options["runtime"]["config"] = config_paths - - # Normalize a final time for fun - return self.normalize_options(cli_options, file=True, cmdline=True) + final_options = self.normalize_options(file_options, file=True, cmdline=True, raw_options_2=cli_options) + return final_options diff --git a/comictaggerlib/settings/types.py b/comictaggerlib/settings/types.py index 7028e89..aee408d 100644 --- a/comictaggerlib/settings/types.py +++ b/comictaggerlib/settings/types.py @@ -10,8 +10,6 @@ from appdirs import AppDirs from comicapi.comicarchive import MetaDataStyle from comicapi.genericmetadata import GenericMetadata -OptionValues = dict[str, dict[str, Any]] - class ComicTaggerPaths(AppDirs): def __init__(self, config_path: pathlib.Path | str | None = None) -> None: diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index daf585e..347fc5d 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -457,7 +457,7 @@ class SettingsWindow(QtWidgets.QDialog): QtWidgets.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.") def reset_settings(self) -> None: - self.options = settings.Manager(self.options["option_definitions"]).defaults() + self.options = settings.Manager(definitions=self.options["option_definitions"]).defaults() self.settings_to_form() QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.") From 53445759f7d430aabfc1bf39047c8aac6a5f4fc6 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Mon, 5 Dec 2022 23:48:38 -0800 Subject: [PATCH 14/22] Add tests --- tests/settings_test.py | 340 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 303 insertions(+), 37 deletions(-) diff --git a/tests/settings_test.py b/tests/settings_test.py index 74fcb38..15667b6 100644 --- a/tests/settings_test.py +++ b/tests/settings_test.py @@ -1,59 +1,325 @@ from __future__ import annotations +import argparse +import json + import pytest -from comictaggerlib import settings as ctsettings +import comictaggerlib.settings.manager def test_settings_manager(): - manager = ctsettings.Manager() + manager = comictaggerlib.settings.manager.Manager() defaults = manager.defaults() assert manager is not None and defaults is not None settings_cases = [ ( - { - "test": ( - { - "args": ["--test"], - "kwargs": dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - ), - }, + ( + ("--test",), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="tst", + exclusive=False, ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "tst", + "help": None, + "internal_name": "tst_test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ("--test",), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": "tst_test", + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, }, - dict({"test": {"test": {"names": ["--test"], "dest": "test", "default": None}}}), - ) + ), + ( + ( + ( + "-t", + "--test", + ), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="tst", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "tst", + "help": None, + "internal_name": "tst_test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ( + "-t", + "--test", + ), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": "tst_test", + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), + ( + ( + ("test",), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="tst", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "tst", + "help": None, + "internal_name": "tst_test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ("tst_test",), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": None, + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), + ( + ( + ("--test",), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "", + "help": None, + "internal_name": "test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ("--test",), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": "test", + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), ] -@pytest.mark.parametrize("settings, expected", settings_cases) -def test_add_setting(settings, expected, settings_manager, tmp_path): - for group, settings in settings.items(): +@pytest.mark.parametrize("arguments, expected", settings_cases) +def test_setting(arguments, expected): + assert vars(comictaggerlib.settings.manager.Setting(*arguments[0], **arguments[1])) == expected - def add_settings(parser): - for setting in settings: - settings_manager.add_setting(*setting["args"], **setting["kwargs"]) - settings_manager.add_group(group, add_settings) +def test_add_setting(settings_manager): + assert settings_manager.add_setting("--test") is None - parsed_settings = settings_manager.parse_options(ctsettings.ComicTaggerPaths(tmp_path), args=[]) - # print(parsed_settings) - # print(expected) - for group, settings in expected.items(): - for setting_name, setting in settings.items(): - assert parsed_settings[group][setting_name] == setting["default"] - assert False +def test_get_defaults(settings_manager): + settings_manager.add_setting("--test", default="hello") + defaults = settings_manager.defaults() + assert defaults[""]["test"] == "hello" + + +def test_get_namespace(settings_manager): + settings_manager.add_setting("--test", default="hello") + defaults = settings_manager.get_namespace(settings_manager.defaults()) + assert defaults.test == "hello" + + +def test_get_defaults_group(settings_manager): + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) + defaults = settings_manager.defaults() + assert defaults["tst"]["test"] == "hello" + + +def test_get_namespace_group(settings_manager): + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) + defaults = settings_manager.get_namespace(settings_manager.defaults()) + assert defaults.tst_test == "hello" + + +def test_cmdline_only(settings_manager): + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", file=False)) + settings_manager.add_group("tst2", lambda parser: parser.add_setting("--test2", default="hello", cmdline=False)) + + file_normalized = settings_manager.normalize_options({}, file=True) + cmdline_normalized = settings_manager.normalize_options({}, cmdline=True) + assert "test" not in file_normalized["tst"] + assert "test" in cmdline_normalized["tst"] + assert "test2" in file_normalized["tst2"] + assert "test2" not in cmdline_normalized["tst2"] + + +def test_normalize(settings_manager): + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) + + defaults = settings_manager.defaults() + defaults["test"] = "fail" + + defaults_namespace = settings_manager.get_namespace(defaults) + defaults_namespace.test = "fail" + + normalized = settings_manager.normalize_options(defaults, True) + normalized_namespace = settings_manager.get_namespace(settings_manager.normalize_options(defaults, True)) + + assert "test" not in normalized + assert "tst" in normalized + assert "test" in normalized["tst"] + assert normalized["tst"]["test"] == "hello" + assert not hasattr(normalized_namespace, "test") + assert hasattr(normalized_namespace, "tst_test") + assert normalized_namespace.tst_test == "hello" + + +@pytest.mark.parametrize( + "raw, raw2, expected", + [ + ({"tst": {"test": "fail"}}, argparse.Namespace(tst_test="success"), "success"), + ({"tst": {"test": "success"}}, argparse.Namespace(tst_test="hello"), "success"), + (argparse.Namespace(tst_test="fail"), {"tst": {"test": "success"}}, "success"), + (argparse.Namespace(tst_test="success"), {"tst": {"test": "hello"}}, "success"), + ], +) +def test_normalize_merge(raw, raw2, expected, settings_manager): + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) + + normalized = settings_manager.normalize_options(raw, True, raw_options_2=raw2) + + assert normalized["tst"]["test"] == expected + + +def test_parse_options(settings_manager, tmp_path): + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"tst2": {"test2": "success"}})) + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", file=False)) + settings_manager.add_group("tst2", lambda parser: parser.add_setting("--test2", default="hello", cmdline=False)) + + normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) + + assert "test" in normalized["tst"] + assert normalized["tst"]["test"] == "success" + assert "test2" in normalized["tst2"] + assert normalized["tst2"]["test2"] == "success" From e9cef87154bdb850f56d7a612abdb4d769f0329d Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Tue, 6 Dec 2022 16:51:10 -0800 Subject: [PATCH 15/22] Move test cases to the testing package Add comments to tests --- testing/settings.py | 214 ++++++++++++++++++++++++++++++++++++++ tests/settings_test.py | 228 +++-------------------------------------- 2 files changed, 226 insertions(+), 216 deletions(-) create mode 100644 testing/settings.py diff --git a/testing/settings.py b/testing/settings.py new file mode 100644 index 0000000..8fe35d2 --- /dev/null +++ b/testing/settings.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +settings_cases = [ + ( + ( + ("--test",), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="tst", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "tst", + "help": None, + "internal_name": "tst_test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ("--test",), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": "tst_test", + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), + ( + ( + ( + "-t", + "--test", + ), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="tst", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "tst", + "help": None, + "internal_name": "tst_test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ( + "-t", + "--test", + ), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": "tst_test", + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), + ( + ( + ("test",), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="tst", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "tst", + "help": None, + "internal_name": "tst_test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ("tst_test",), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": None, + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), + ( + ( + ("--test",), + dict( + action=None, + nargs=None, + const=None, + default=None, + type=None, + choices=None, + required=None, + help=None, + metavar=None, + dest=None, + cmdline=True, + file=True, + group="", + exclusive=False, + ), + ), + { + "action": None, + "choices": None, + "cmdline": True, + "const": None, + "default": None, + "dest": "test", + "exclusive": False, + "file": True, + "group": "", + "help": None, + "internal_name": "test", + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + "argparse_args": ("--test",), + "argparse_kwargs": { + "action": None, + "choices": None, + "const": None, + "default": None, + "dest": "test", + "help": None, + "metavar": "TEST", + "nargs": None, + "required": None, + "type": None, + }, + }, + ), +] diff --git a/tests/settings_test.py b/tests/settings_test.py index 15667b6..bfe8a2f 100644 --- a/tests/settings_test.py +++ b/tests/settings_test.py @@ -6,6 +6,7 @@ import json import pytest import comictaggerlib.settings.manager +from testing.settings import settings_cases def test_settings_manager(): @@ -14,220 +15,6 @@ def test_settings_manager(): assert manager is not None and defaults is not None -settings_cases = [ - ( - ( - ("--test",), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="tst", - exclusive=False, - ), - ), - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "tst", - "help": None, - "internal_name": "tst_test", - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ("--test",), - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": "tst_test", - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), - ( - ( - ( - "-t", - "--test", - ), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="tst", - exclusive=False, - ), - ), - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "tst", - "help": None, - "internal_name": "tst_test", - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ( - "-t", - "--test", - ), - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": "tst_test", - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), - ( - ( - ("test",), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="tst", - exclusive=False, - ), - ), - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "tst", - "help": None, - "internal_name": "tst_test", - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ("tst_test",), - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": None, - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), - ( - ( - ("--test",), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="", - exclusive=False, - ), - ), - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "", - "help": None, - "internal_name": "test", - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ("--test",), - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": "test", - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), -] - - @pytest.mark.parametrize("arguments, expected", settings_cases) def test_setting(arguments, expected): assert vars(comictaggerlib.settings.manager.Setting(*arguments[0], **arguments[1])) == expected @@ -289,6 +76,7 @@ def test_normalize(settings_manager): assert "tst" in normalized assert "test" in normalized["tst"] assert normalized["tst"]["test"] == "hello" + assert not hasattr(normalized_namespace, "test") assert hasattr(normalized_namespace, "tst_test") assert normalized_namespace.tst_test == "hello" @@ -313,13 +101,21 @@ def test_normalize_merge(raw, raw2, expected, settings_manager): def test_parse_options(settings_manager, tmp_path): settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({"tst2": {"test2": "success"}})) + settings_file.write_text(json.dumps({"tst2": {"test2": "success"}, "tst3": {"test3": "fail"}})) settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", file=False)) settings_manager.add_group("tst2", lambda parser: parser.add_setting("--test2", default="hello", cmdline=False)) + settings_manager.add_group("tst3", lambda parser: parser.add_setting("--test3", default="hello")) - normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) + normalized = settings_manager.parse_options(settings_file, ["--test", "success", "--test3", "success"]) + # Tests that the cli will override the default assert "test" in normalized["tst"] assert normalized["tst"]["test"] == "success" + + # Tests that the settings file will override the default assert "test2" in normalized["tst2"] assert normalized["tst2"]["test2"] == "success" + + # Tests that the cli will override the settings file + assert "test3" in normalized["tst3"] + assert normalized["tst3"]["test3"] == "success" From f3917c6e4dcad1beba1d72365d00af1f98266d69 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sat, 10 Dec 2022 17:58:26 -0800 Subject: [PATCH 16/22] Add comments to tests --- testing/settings.py | 24 ++++++++++++------------ tests/settings_test.py | 9 ++++++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/testing/settings.py b/testing/settings.py index 8fe35d2..dc0fe31 100644 --- a/testing/settings.py +++ b/testing/settings.py @@ -20,24 +20,24 @@ settings_cases = [ group="tst", exclusive=False, ), - ), + ), # Equivalent to Setting("--test", group="tst") { "action": None, "choices": None, "cmdline": True, "const": None, "default": None, - "dest": "test", + "dest": "test", # dest is calculated by Setting and is not used by argparse "exclusive": False, "file": True, "group": "tst", "help": None, - "internal_name": "tst_test", - "metavar": "TEST", + "internal_name": "tst_test", # Should almost always be "{group}_{dest}" + "metavar": "TEST", # Set manually so argparse doesn't use TST_TEST "nargs": None, "required": None, "type": None, - "argparse_args": ("--test",), + "argparse_args": ("--test",), # *args actually sent to argparse "argparse_kwargs": { "action": None, "choices": None, @@ -49,7 +49,7 @@ settings_cases = [ "nargs": None, "required": None, "type": None, - }, + }, # Non-None **kwargs sent to argparse }, ), ( @@ -74,7 +74,7 @@ settings_cases = [ group="tst", exclusive=False, ), - ), + ), # Equivalent to Setting("-t", "--test", group="tst") { "action": None, "choices": None, @@ -94,7 +94,7 @@ settings_cases = [ "argparse_args": ( "-t", "--test", - ), + ), # Only difference with above is here "argparse_kwargs": { "action": None, "choices": None, @@ -128,7 +128,7 @@ settings_cases = [ group="tst", exclusive=False, ), - ), + ), # Equivalent to Setting("test", group="tst") { "action": None, "choices": None, @@ -151,7 +151,7 @@ settings_cases = [ "choices": None, "const": None, "default": None, - "dest": None, + "dest": None, # Only difference with #1 is here, argparse sets dest based on the *args passed to it "help": None, "metavar": "TEST", "nargs": None, @@ -179,7 +179,7 @@ settings_cases = [ group="", exclusive=False, ), - ), + ), # Equivalent to Setting("test") { "action": None, "choices": None, @@ -191,7 +191,7 @@ settings_cases = [ "file": True, "group": "", "help": None, - "internal_name": "test", + "internal_name": "test", # No group, leading _ is stripped "metavar": "TEST", "nargs": None, "required": None, diff --git a/tests/settings_test.py b/tests/settings_test.py index bfe8a2f..f7fcb14 100644 --- a/tests/settings_test.py +++ b/tests/settings_test.py @@ -54,17 +54,19 @@ def test_cmdline_only(settings_manager): file_normalized = settings_manager.normalize_options({}, file=True) cmdline_normalized = settings_manager.normalize_options({}, cmdline=True) - assert "test" not in file_normalized["tst"] + assert "test" in cmdline_normalized["tst"] - assert "test2" in file_normalized["tst2"] assert "test2" not in cmdline_normalized["tst2"] + assert "test" not in file_normalized["tst"] + assert "test2" in file_normalized["tst2"] + def test_normalize(settings_manager): settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) defaults = settings_manager.defaults() - defaults["test"] = "fail" + defaults["test"] = "fail" # Not defined in settings_manager defaults_namespace = settings_manager.get_namespace(defaults) defaults_namespace.test = "fail" @@ -86,6 +88,7 @@ def test_normalize(settings_manager): "raw, raw2, expected", [ ({"tst": {"test": "fail"}}, argparse.Namespace(tst_test="success"), "success"), + # hello is default so is not used in raw_options_2 ({"tst": {"test": "success"}}, argparse.Namespace(tst_test="hello"), "success"), (argparse.Namespace(tst_test="fail"), {"tst": {"test": "success"}}, "success"), (argparse.Namespace(tst_test="success"), {"tst": {"test": "hello"}}, "success"), From 48c6372cf4ad710591d548196b93bb520e8983f4 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Sat, 10 Dec 2022 18:35:41 -0800 Subject: [PATCH 17/22] Fix --no-overwrite --- comictaggerlib/settings/cmdline.py | 4 +--- comictaggerlib/settings/file.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/comictaggerlib/settings/cmdline.py b/comictaggerlib/settings/cmdline.py index 1461595..d5920eb 100644 --- a/comictaggerlib/settings/cmdline.py +++ b/comictaggerlib/settings/cmdline.py @@ -191,9 +191,7 @@ def register_options(parser: Manager) -> None: file=False, ) parser.add_setting( - # "--no-overwrite", - # "--nooverwrite", - "--temporary", + "--no-overwrite", dest="no_overwrite", action="store_true", help="""Don't modify tag block if it already exists (relevant for -s or -c).""", diff --git a/comictaggerlib/settings/file.py b/comictaggerlib/settings/file.py index a5e9f00..2a528b9 100644 --- a/comictaggerlib/settings/file.py +++ b/comictaggerlib/settings/file.py @@ -94,9 +94,9 @@ def comicvine(parser: Manager) -> None: ) parser.add_setting("--use-series-start-as-volume", default=False, action=argparse.BooleanOptionalAction) parser.add_setting( - "--overwrite", + "--clear-metadata", default=True, - help="Overwrite all existing metadata.\nMay be used in conjunction with -o, -f and -m.\n\n", + 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, ) From 18566a05920b3f428c771b17f97b201c70c9c9dc Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Tue, 13 Dec 2022 08:50:08 -0800 Subject: [PATCH 18/22] Fix setting cmdline arguments --- comictaggerlib/settings/manager.py | 34 +++++++++--------- tests/settings_test.py | 58 ++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/comictaggerlib/settings/manager.py b/comictaggerlib/settings/manager.py index 28fab66..bd65e8e 100644 --- a/comictaggerlib/settings/manager.py +++ b/comictaggerlib/settings/manager.py @@ -139,7 +139,7 @@ class Manager: def defaults(self) -> OptionValues: return self.normalize_options({}, file=True, cmdline=True) - def get_namespace(self, options: OptionValues) -> argparse.Namespace: + def get_namespace(self, options: OptionValues, defaults: bool = True) -> argparse.Namespace: """ Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" `options` should already be normalized. @@ -150,14 +150,13 @@ class Manager: for setting_name, setting in group.items(): if hasattr(namespace, setting.internal_name): raise Exception(f"Duplicate internal name: {setting.internal_name}") - setattr( - namespace, - setting.internal_name, - options.get(group_name, {}).get( - setting.dest, - setting.default, - ), - ) + value, default = self.get_option(options, setting, group_name) + if not default or default and defaults: + setattr( + namespace, + setting.internal_name, + value, + ) setattr(namespace, "option_definitions", options.get("option_definitions")) return namespace @@ -167,7 +166,7 @@ class Manager: self.option_definitions[self.current_group_name][setting.dest] = setting def create_argparser(self) -> None: - """Creates an argparser object from all cmdline settings""" + """Creates an :class:`argparse.ArgumentParser` from all cmdline settings""" groups: dict[str, ArgParser] = {} self.argparser = argparse.ArgumentParser( description=self.description, @@ -201,7 +200,7 @@ class Manager: self.exclusive_group = False def exit(self, *args: Any, **kwargs: Any) -> NoReturn: - """Same as `argparser.ArgParser.exit`""" + """Same as :class:`~argparse.ArgumentParser`""" self.argparser.exit(*args, **kwargs) raise SystemExit(99) @@ -248,6 +247,7 @@ class Manager: raw_options: OptionValues | argparse.Namespace, file: bool = False, cmdline: bool = False, + defaults: bool = True, raw_options_2: OptionValues | argparse.Namespace | None = None, ) -> OptionValues: """ @@ -261,9 +261,11 @@ class Manager: for setting_name, setting in group.items(): if (setting.cmdline and cmdline) or (setting.file and file): # Ensures the option exists with the default if not already set - group_options[setting_name], _ = self.get_option(raw_options, setting, group_name) + value, default = self.get_option(raw_options, setting, group_name) + if not default or default and defaults: + group_options[setting_name] = value - # will override with option from raw_options_2 if it exists + # will override with option from raw_options_2 if it is not the default if raw_options_2 is not None: value, default = self.get_option(raw_options_2, setting, group_name) if not default: @@ -290,11 +292,11 @@ class Manager: self.create_argparser() ns = self.argparser.parse_args(args, namespace=namespace) - return self.normalize_options(ns, cmdline=True, file=False) + return self.normalize_options(ns, cmdline=True, file=True) def parse_options(self, config_path: pathlib.Path, args: list[str] | None = None) -> OptionValues: file_options = self.parse_file(config_path) - cli_options = self.parse_args(args) + cli_options = self.parse_args(args, self.get_namespace(file_options, defaults=False)) - final_options = self.normalize_options(file_options, file=True, cmdline=True, raw_options_2=cli_options) + final_options = self.normalize_options(cli_options, file=True, cmdline=True) return final_options diff --git a/tests/settings_test.py b/tests/settings_test.py index f7fcb14..7072555 100644 --- a/tests/settings_test.py +++ b/tests/settings_test.py @@ -71,8 +71,8 @@ def test_normalize(settings_manager): defaults_namespace = settings_manager.get_namespace(defaults) defaults_namespace.test = "fail" - normalized = settings_manager.normalize_options(defaults, True) - normalized_namespace = settings_manager.get_namespace(settings_manager.normalize_options(defaults, True)) + normalized = settings_manager.normalize_options(defaults, file=True) + normalized_namespace = settings_manager.get_namespace(settings_manager.normalize_options(defaults, file=True)) assert "test" not in normalized assert "tst" in normalized @@ -97,28 +97,56 @@ def test_normalize(settings_manager): def test_normalize_merge(raw, raw2, expected, settings_manager): settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) - normalized = settings_manager.normalize_options(raw, True, raw_options_2=raw2) + normalized = settings_manager.normalize_options(raw, file=True, raw_options_2=raw2) assert normalized["tst"]["test"] == expected -def test_parse_options(settings_manager, tmp_path): +def test_cli_set(settings_manager, tmp_path): settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({"tst2": {"test2": "success"}, "tst3": {"test3": "fail"}})) + settings_file.write_text(json.dumps({})) settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", file=False)) - settings_manager.add_group("tst2", lambda parser: parser.add_setting("--test2", default="hello", cmdline=False)) - settings_manager.add_group("tst3", lambda parser: parser.add_setting("--test3", default="hello")) - normalized = settings_manager.parse_options(settings_file, ["--test", "success", "--test3", "success"]) + normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) - # Tests that the cli will override the default assert "test" in normalized["tst"] assert normalized["tst"]["test"] == "success" - # Tests that the settings file will override the default - assert "test2" in normalized["tst2"] - assert normalized["tst2"]["test2"] == "success" - # Tests that the cli will override the settings file - assert "test3" in normalized["tst3"] - assert normalized["tst3"]["test3"] == "success" +def test_file_set(settings_manager, tmp_path): + settings_file = tmp_path / "settings.json" + settings_file.write_text( + json.dumps( + { + "tst": {"test": "success"}, + } + ) + ) + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", cmdline=False)) + + normalized = settings_manager.parse_options(settings_file, []) + + assert "test" in normalized["tst"] + assert normalized["tst"]["test"] == "success" + + +def test_cli_override_file(settings_manager, tmp_path): + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"tst": {"test": "fail"}})) + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) + + normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) + + assert "test" in normalized["tst"] + assert normalized["tst"]["test"] == "success" + + +def test_cli_explicit_default(settings_manager, tmp_path): + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({"tst": {"test": "fail"}})) + settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="success")) + + normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) + + assert "test" in normalized["tst"] + assert normalized["tst"]["test"] == "success" From eca421e0f2079ad8992fbaa5956a59d314a9c8a0 Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Tue, 13 Dec 2022 08:50:38 -0800 Subject: [PATCH 19/22] Split out settings functions --- comictaggerlib/settings/__init__.py | 4 +- comictaggerlib/settings/manager.py | 303 ++++++++++++++++++---------- comictaggerlib/settingswindow.py | 8 +- comictaggerlib/taggerwindow.py | 6 +- 4 files changed, 214 insertions(+), 107 deletions(-) diff --git a/comictaggerlib/settings/__init__.py b/comictaggerlib/settings/__init__.py index a7c069b..c4c4a90 100644 --- a/comictaggerlib/settings/__init__.py +++ b/comictaggerlib/settings/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from comictaggerlib.settings.cmdline import initial_cmd_line_parser, register_commandline, validate_commandline_options from comictaggerlib.settings.file import register_settings, validate_settings -from comictaggerlib.settings.manager import Manager, OptionDefinitions, OptionValues +from comictaggerlib.settings.manager import Manager, OptionDefinitions, OptionValues, defaults, save_file from comictaggerlib.settings.types import ComicTaggerPaths __all__ = [ @@ -15,4 +15,6 @@ __all__ = [ "ComicTaggerPaths", "OptionValues", "OptionDefinitions", + "save_file", + "defaults", ] diff --git a/comictaggerlib/settings/manager.py b/comictaggerlib/settings/manager.py index bd65e8e..fa34606 100644 --- a/comictaggerlib/settings/manager.py +++ b/comictaggerlib/settings/manager.py @@ -6,7 +6,7 @@ import logging import pathlib from collections import defaultdict from collections.abc import Sequence -from typing import Any, Callable, NoReturn, Union +from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union logger = logging.getLogger(__name__) @@ -115,7 +115,162 @@ class Setting: OptionValues = dict[str, dict[str, Any]] OptionDefinitions = dict[str, dict[str, Setting]] -ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] +if TYPE_CHECKING: + ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] + + +def get_option(options: OptionValues | argparse.Namespace, setting: Setting) -> tuple[Any, bool]: + """ + Helper function to retrieve the value for a setting and if the value is the default value + + Args: + options: Dictionary or namespace of options + setting: The setting object describing the value to retrieve + """ + if isinstance(options, dict): + value = options.get(setting.group, {}).get(setting.dest, setting.default) + else: + value = getattr(options, setting.internal_name, setting.default) + return value, value == setting.default + + +def normalize_options( + raw_options: OptionValues | argparse.Namespace, + definitions: OptionDefinitions, + file: bool = False, + cmdline: bool = False, + defaults: bool = True, + raw_options_2: OptionValues | argparse.Namespace | None = None, +) -> OptionValues: + """ + Creates an `OptionValues` dictionary with setting definitions taken from `self.definitions` + and values taken from `raw_options` and `raw_options_2' if defined. + Values are assigned so if the value is a dictionary mutating it will mutate the original. + + Args: + raw_options: The dict or Namespace to normalize options from + definitions: The definition of the options + file: Include file options + cmdline: Include cmdline options + defaults: Include default values in the returned dict + raw_options_2: If set, merges non-default values into the returned dict + """ + options: OptionValues = {} + for group_name, group in definitions.items(): + group_options = {} + for setting_name, setting in group.items(): + if (setting.cmdline and cmdline) or (setting.file and file): + # Ensures the option exists with the default if not already set + value, default = get_option(raw_options, setting) + if not default or default and defaults: + group_options[setting_name] = value + + # will override with option from raw_options_2 if it is not the default + if raw_options_2 is not None: + value, default = get_option(raw_options_2, setting) + if not default: + group_options[setting_name] = value + options[group_name] = group_options + options["definitions"] = definitions + return options + + +def parse_file(filename: pathlib.Path, definitions: OptionDefinitions) -> OptionValues: + """ + Helper function to read options from a json dictionary from a file + Args: + filename: A pathlib.Path object to read a json dictionary from + """ + options: OptionValues = {} + if filename.exists(): + try: + with filename.open() as file: + opts = json.load(file) + if isinstance(opts, dict): + options = opts + except Exception: + logger.exception("Failed to load config file: %s", filename) + else: + logger.info("No config file found") + + return normalize_options(options, definitions, file=True) + + +def clean_options( + options: OptionValues | argparse.Namespace, definitions: OptionDefinitions, file: bool = False, cmdline: bool = True +) -> OptionValues: + """ + Normalizes options and then cleans up empty groups and removes 'definitions' + Args: + options: + file: + cmdline: + + Returns: + + """ + + clean_options = normalize_options(options, definitions, file=file, cmdline=cmdline) + del clean_options["definitions"] + for group in list(clean_options.keys()): + if not clean_options[group]: + del clean_options[group] + return clean_options + + +def defaults(definitions: OptionDefinitions) -> OptionValues: + return normalize_options({}, definitions, file=True, cmdline=True) + + +def get_namespace(options: OptionValues, definitions: OptionDefinitions, defaults: bool = True) -> argparse.Namespace: + """ + Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" + `options` should already be normalized. + Throws an exception if the internal_name is duplicated + + Args: + options: Normalized options to turn into a Namespace + defaults: Include default values in the returned dict + """ + options = normalize_options(options, definitions, file=True, cmdline=True) + namespace = argparse.Namespace() + for group_name, group in definitions.items(): + for setting_name, setting in group.items(): + if hasattr(namespace, setting.internal_name): + raise Exception(f"Duplicate internal name: {setting.internal_name}") + value, default = get_option(options, setting) + + if not default or default and defaults: + setattr( + namespace, + setting.internal_name, + value, + ) + setattr(namespace, "definitions", definitions) + return namespace + + +def save_file( + options: OptionValues | argparse.Namespace, definitions: OptionDefinitions, filename: pathlib.Path +) -> bool: + """ + Helper function to save options from a json dictionary to a file + Args: + options: The options to save to a json dictionary + filename: A pathlib.Path object to save the json dictionary to + """ + file_options = clean_options(options, definitions, file=True) + if not filename.exists(): + filename.parent.mkdir(exist_ok=True, parents=True) + filename.touch() + + try: + json_str = json.dumps(file_options, indent=2) + filename.write_text(json_str, encoding="utf-8") + except Exception: + logger.exception("Failed to save config file: %s", filename) + return False + return True class Manager: @@ -129,41 +284,17 @@ class Manager: self.description = description self.epilog = epilog - self.option_definitions: OptionDefinitions = defaultdict(lambda: dict()) + self.definitions: OptionDefinitions = defaultdict(lambda: dict()) if definitions: - self.option_definitions = definitions + self.definitions = definitions self.exclusive_group = False self.current_group_name = "" - def defaults(self) -> OptionValues: - return self.normalize_options({}, file=True, cmdline=True) - - def get_namespace(self, options: OptionValues, defaults: bool = True) -> argparse.Namespace: - """ - Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" - `options` should already be normalized. - Throws an exception if the internal_name is duplicated - """ - namespace = argparse.Namespace() - for group_name, group in self.option_definitions.items(): - for setting_name, setting in group.items(): - if hasattr(namespace, setting.internal_name): - raise Exception(f"Duplicate internal name: {setting.internal_name}") - value, default = self.get_option(options, setting, group_name) - if not default or default and defaults: - setattr( - namespace, - setting.internal_name, - value, - ) - setattr(namespace, "option_definitions", options.get("option_definitions")) - return namespace - def add_setting(self, *args: Any, **kwargs: Any) -> None: """Takes passes all arguments through to `Setting`, `group` and `exclusive` are already set""" setting = Setting(*args, group=self.current_group_name, exclusive=self.exclusive_group, **kwargs) - self.option_definitions[self.current_group_name][setting.dest] = setting + self.definitions[self.current_group_name][setting.dest] = setting def create_argparser(self) -> None: """Creates an :class:`argparse.ArgumentParser` from all cmdline settings""" @@ -173,7 +304,7 @@ class Manager: epilog=self.epilog, formatter_class=argparse.RawTextHelpFormatter, ) - for group_name, group in self.option_definitions.items(): + for group_name, group in self.definitions.items(): for setting_name, setting in group.items(): if setting.cmdline: argparse_args, argparse_kwargs = setting.to_argparse() @@ -193,6 +324,14 @@ class Manager: current_group.add_argument(*argparse_args, **argparse_kwargs) def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None: + """ + The primary way to add define options on this class + + Args: + name: The name of the group to define + add_settings: A function that registers individual options using :meth:`add_setting` + exclusive_group: If this group is an argparse exclusive group + """ self.current_group_name = name self.exclusive_group = exclusive_group add_settings(self) @@ -200,47 +339,17 @@ class Manager: self.exclusive_group = False def exit(self, *args: Any, **kwargs: Any) -> NoReturn: - """Same as :class:`~argparse.ArgumentParser`""" + """See :class:`~argparse.ArgumentParser`""" self.argparser.exit(*args, **kwargs) raise SystemExit(99) - def save_file(self, options: OptionValues | argparse.Namespace, filename: pathlib.Path) -> bool: - if isinstance(options, dict): - self.option_definitions = options["option_definitions"] - elif isinstance(options, argparse.Namespace): - self.option_definitions = options.option_definitions + def defaults(self) -> OptionValues: + return defaults(self.definitions) - file_options = self.normalize_options(options, file=True) - del file_options["option_definitions"] - for group in list(file_options.keys()): - if not file_options[group]: - del file_options[group] - if not filename.exists(): - filename.parent.mkdir(exist_ok=True, parents=True) - filename.touch() - - try: - json_str = json.dumps(file_options, indent=2) - filename.write_text(json_str, encoding="utf-8") - except Exception: - logger.exception("Failed to save config file: %s", filename) - return False - return True - - def parse_file(self, filename: pathlib.Path) -> OptionValues: - options: OptionValues = {} - if filename.exists(): - try: - with filename.open() as file: - opts = json.load(file) - if isinstance(opts, dict): - options = opts - except Exception: - logger.exception("Failed to load config file: %s", filename) - else: - logger.info("No config file found") - - return self.normalize_options(options, file=True) + def clean_options( + self, options: OptionValues | argparse.Namespace, file: bool = False, cmdline: bool = True + ) -> OptionValues: + return clean_options(options=options, definitions=self.definitions, file=file, cmdline=cmdline) def normalize_options( self, @@ -250,53 +359,43 @@ class Manager: defaults: bool = True, raw_options_2: OptionValues | argparse.Namespace | None = None, ) -> OptionValues: - """ - Creates an `OptionValues` dictionary with setting definitions taken from `self.option_definitions` - and values taken from `raw_options` and `raw_options_2' if defined. - Values are assigned so if the value is a dictionary mutating it will mutate the original. - """ - options: OptionValues = {} - for group_name, group in self.option_definitions.items(): - group_options = {} - for setting_name, setting in group.items(): - if (setting.cmdline and cmdline) or (setting.file and file): - # Ensures the option exists with the default if not already set - value, default = self.get_option(raw_options, setting, group_name) - if not default or default and defaults: - group_options[setting_name] = value + return normalize_options( + raw_options=raw_options, + definitions=self.definitions, + file=file, + cmdline=cmdline, + defaults=defaults, + raw_options_2=raw_options_2, + ) - # will override with option from raw_options_2 if it is not the default - if raw_options_2 is not None: - value, default = self.get_option(raw_options_2, setting, group_name) - if not default: - group_options[setting_name] = value - options[group_name] = group_options - options["option_definitions"] = self.option_definitions - return options + def get_namespace(self, options: OptionValues, defaults: bool = True) -> argparse.Namespace: + return get_namespace(options=options, definitions=self.definitions, defaults=defaults) - def get_option( - self, options: OptionValues | argparse.Namespace, setting: Setting, group_name: str - ) -> tuple[Any, bool]: - """Helper function to retrieve the the value for a setting and if the value is the default value""" - if isinstance(options, dict): - value = options.get(group_name, {}).get(setting.dest, setting.default) - else: - value = getattr(options, setting.internal_name, setting.default) - return value, value == setting.default + def parse_file(self, filename: pathlib.Path) -> OptionValues: + return parse_file(filename=filename, definitions=self.definitions) + + def save_file(self, options: OptionValues | argparse.Namespace, filename: pathlib.Path) -> bool: + return save_file(options=options, definitions=self.definitions, filename=filename) def parse_args(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> OptionValues: """ - Creates an `argparse.ArgumentParser` from cmdline settings in `self.option_definitions`. + Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`. `args` and `namespace` are passed to `argparse.ArgumentParser.parse_args` + + Args: + args: Passed to argparse.ArgumentParser.parse + namespace: Passed to argparse.ArgumentParser.parse """ self.create_argparser() ns = self.argparser.parse_args(args, namespace=namespace) - return self.normalize_options(ns, cmdline=True, file=True) + return normalize_options(ns, definitions=self.definitions, cmdline=True, file=True) def parse_options(self, config_path: pathlib.Path, args: list[str] | None = None) -> OptionValues: - file_options = self.parse_file(config_path) + file_options = self.parse_file( + config_path, + ) cli_options = self.parse_args(args, self.get_namespace(file_options, defaults=False)) - final_options = self.normalize_options(cli_options, file=True, cmdline=True) + final_options = normalize_options(cli_options, definitions=self.definitions, file=True, cmdline=True) return final_options diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 347fc5d..cb164c4 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -438,7 +438,11 @@ class SettingsWindow(QtWidgets.QDialog): self.options["rename"]["strict"] = self.cbxRenameStrict.isChecked() self.options["rename"]["replacements"] = self.get_replacemnts() - settings.Manager().save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") + settings.save_file( + self.options, + self.options["definitions"], + self.options["runtime"]["config"].user_config_dir / "settings.json", + ) self.parent().options = self.options QtWidgets.QDialog.accept(self) @@ -457,7 +461,7 @@ class SettingsWindow(QtWidgets.QDialog): QtWidgets.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.") def reset_settings(self) -> None: - self.options = settings.Manager(definitions=self.options["option_definitions"]).defaults() + self.options = settings.defaults(self.options["definitions"]) self.settings_to_form() QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.") diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index fb54d41..3ec4f5a 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -2003,8 +2003,10 @@ Have fun! self.options["internal"]["last_filelist_sorted_column"], self.options["internal"]["last_filelist_sorted_order"], ) = self.fileSelectionList.get_sorting() - settings.Manager().save_file( - self.options, self.options["runtime"]["config"].user_config_dir / "settings.json" + settings.save_file( + self.options, + self.options["definitions"], + self.options["runtime"]["config"].user_config_dir / "settings.json", ) event.accept() From 103379e5484136328eac97f7d551f52564154c5a Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Wed, 14 Dec 2022 23:13:53 -0800 Subject: [PATCH 20/22] Split settings out into a separate package --- comictaggerlib/autotagmatchwindow.py | 4 +- comictaggerlib/autotagstartwindow.py | 4 +- comictaggerlib/cbltransformer.py | 5 +- comictaggerlib/cli.py | 6 +- comictaggerlib/ctoptions/__init__.py | 14 + .../{settings => ctoptions}/cmdline.py | 14 +- .../{settings => ctoptions}/file.py | 27 +- .../{settings => ctoptions}/types.py | 0 comictaggerlib/fileselectionlist.py | 4 +- comictaggerlib/gui.py | 13 +- comictaggerlib/issueidentifier.py | 4 +- comictaggerlib/issueselectionwindow.py | 4 +- comictaggerlib/main.py | 81 ++-- comictaggerlib/matchselectionwindow.py | 4 +- comictaggerlib/renamewindow.py | 36 +- comictaggerlib/settings/__init__.py | 20 - comictaggerlib/settings/manager.py | 401 ------------------ comictaggerlib/settingswindow.py | 166 ++++---- comictaggerlib/taggerwindow.py | 147 +++---- comictaggerlib/volumeselectionwindow.py | 4 +- requirements.txt | 1 + testing/settings.py | 214 ---------- tests/comiccacher_test.py | 6 +- tests/conftest.py | 22 +- tests/issueidentifier_test.py | 16 +- tests/settings_test.py | 152 ------- 26 files changed, 307 insertions(+), 1062 deletions(-) create mode 100644 comictaggerlib/ctoptions/__init__.py rename comictaggerlib/{settings => ctoptions}/cmdline.py (96%) rename comictaggerlib/{settings => ctoptions}/file.py (94%) rename comictaggerlib/{settings => ctoptions}/types.py (100%) delete mode 100644 comictaggerlib/settings/__init__.py delete mode 100644 comictaggerlib/settings/manager.py delete mode 100644 testing/settings.py delete mode 100644 tests/settings_test.py diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py index 4baf51d..59496e4 100644 --- a/comictaggerlib/autotagmatchwindow.py +++ b/comictaggerlib/autotagmatchwindow.py @@ -19,11 +19,11 @@ import logging import os from typing import Callable +import settngs from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi.comicarchive import MetaDataStyle from comicapi.genericmetadata import GenericMetadata -from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.resulttypes import IssueResult, MultipleMatch from comictaggerlib.ui import ui_path @@ -42,7 +42,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog): match_set_list: list[MultipleMatch], style: int, fetch_func: Callable[[IssueResult], GenericMetadata], - options: settings.OptionValues, + options: settngs.ConfigValues, talker_api: ComicTalker, ) -> None: super().__init__(parent) diff --git a/comictaggerlib/autotagstartwindow.py b/comictaggerlib/autotagstartwindow.py index 4ee1c5f..5b8901c 100644 --- a/comictaggerlib/autotagstartwindow.py +++ b/comictaggerlib/autotagstartwindow.py @@ -17,16 +17,16 @@ from __future__ import annotations import logging +import settngs from PyQt5 import QtCore, QtWidgets, uic -from comictaggerlib import settings from comictaggerlib.ui import ui_path logger = logging.getLogger(__name__) class AutoTagStartWindow(QtWidgets.QDialog): - def __init__(self, parent: QtWidgets.QWidget, options: settings.OptionValues, msg: str) -> None: + def __init__(self, parent: QtWidgets.QWidget, options: settngs.ConfigValues, msg: str) -> None: super().__init__(parent) uic.loadUi(ui_path / "autotagstartwindow.ui", self) diff --git a/comictaggerlib/cbltransformer.py b/comictaggerlib/cbltransformer.py index c6d9726..ec0e59d 100644 --- a/comictaggerlib/cbltransformer.py +++ b/comictaggerlib/cbltransformer.py @@ -17,14 +17,15 @@ from __future__ import annotations import logging +import settngs + from comicapi.genericmetadata import CreditMetadata, GenericMetadata -from comictaggerlib import settings logger = logging.getLogger(__name__) class CBLTransformer: - def __init__(self, metadata: GenericMetadata, options: settings.OptionValues) -> None: + def __init__(self, metadata: GenericMetadata, options: settngs.ConfigValues) -> None: self.metadata = metadata self.options = options diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index a51bc01..f01dd04 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -23,10 +23,12 @@ import sys from datetime import datetime from pprint import pprint +import settngs + from comicapi import utils from comicapi.comicarchive import ComicArchive, MetaDataStyle from comicapi.genericmetadata import GenericMetadata -from comictaggerlib import ctversion, settings +from comictaggerlib import ctversion from comictaggerlib.cbltransformer import CBLTransformer from comictaggerlib.filerenamer import FileRenamer, get_rename_dir from comictaggerlib.graphics import graphics_path @@ -38,7 +40,7 @@ logger = logging.getLogger(__name__) class CLI: - def __init__(self, options: settings.OptionValues, talker_api: ComicTalker): + def __init__(self, options: settngs.Values, talker_api: ComicTalker): self.options = options self.talker_api = talker_api self.batch_mode = False diff --git a/comictaggerlib/ctoptions/__init__.py b/comictaggerlib/ctoptions/__init__.py new file mode 100644 index 0000000..fd1f386 --- /dev/null +++ b/comictaggerlib/ctoptions/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from comictaggerlib.ctoptions.cmdline import initial_cmd_line_parser, register_commandline, validate_commandline_options +from comictaggerlib.ctoptions.file import register_settings, validate_settings +from comictaggerlib.ctoptions.types import ComicTaggerPaths + +__all__ = [ + "initial_cmd_line_parser", + "register_commandline", + "register_settings", + "validate_commandline_options", + "validate_settings", + "ComicTaggerPaths", +] diff --git a/comictaggerlib/settings/cmdline.py b/comictaggerlib/ctoptions/cmdline.py similarity index 96% rename from comictaggerlib/settings/cmdline.py rename to comictaggerlib/ctoptions/cmdline.py index d5920eb..087f972 100644 --- a/comictaggerlib/settings/cmdline.py +++ b/comictaggerlib/ctoptions/cmdline.py @@ -19,13 +19,13 @@ import argparse import logging import os import platform -from typing import Any + +import settngs from comicapi import utils from comicapi.genericmetadata import GenericMetadata from comictaggerlib import ctversion -from comictaggerlib.settings.manager import Manager -from comictaggerlib.settings.types import ComicTaggerPaths, metadata_type, parse_metadata_from_string +from comictaggerlib.ctoptions.types import ComicTaggerPaths, metadata_type, parse_metadata_from_string logger = logging.getLogger(__name__) @@ -49,7 +49,7 @@ def initial_cmd_line_parser() -> argparse.ArgumentParser: return parser -def register_options(parser: Manager) -> None: +def register_options(parser: settngs.Manager) -> None: parser.add_setting( "--config", help="Config directory defaults to ~/.Config/ComicTagger\non Linux, ~/Library/Application Support/ComicTagger on Mac and %%APPDATA%%\\ComicTagger on Windows\n", @@ -200,7 +200,7 @@ def register_options(parser: Manager) -> None: parser.add_setting("files", nargs="*", file=False) -def register_commands(parser: Manager) -> None: +def register_commands(parser: settngs.Manager) -> None: parser.add_setting( "--version", action="store_true", @@ -259,12 +259,12 @@ def register_commands(parser: Manager) -> None: ) -def register_commandline(parser: Manager) -> None: +def register_commandline(parser: settngs.Manager) -> None: parser.add_group("commands", register_commands, True) parser.add_group("runtime", register_options) -def validate_commandline_options(options: dict[str, dict[str, Any]], parser: Manager) -> dict[str, dict[str, Any]]: +def validate_commandline_options(options: settngs.Values, parser: settngs.Manager) -> settngs.Values: if options["commands"]["version"]: parser.exit( diff --git a/comictaggerlib/settings/file.py b/comictaggerlib/ctoptions/file.py similarity index 94% rename from comictaggerlib/settings/file.py rename to comictaggerlib/ctoptions/file.py index 2a528b9..1d50643 100644 --- a/comictaggerlib/settings/file.py +++ b/comictaggerlib/ctoptions/file.py @@ -4,12 +4,13 @@ import argparse import uuid from typing import Any +import settngs + +from comictaggerlib.ctoptions.types import AppendAction from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements -from comictaggerlib.settings.manager import Manager -from comictaggerlib.settings.types import AppendAction -def general(parser: Manager) -> None: +def general(parser: settngs.Manager) -> None: # General Settings parser.add_setting("--rar-exe-path", default="rar", help="The path to the rar program") parser.add_setting( @@ -22,7 +23,7 @@ def general(parser: Manager) -> None: parser.add_setting("send_usage_stats", default=False, cmdline=False) -def internal(parser: Manager) -> None: +def internal(parser: settngs.Manager) -> None: # automatic settings parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False) parser.add_setting("last_selected_save_data_style", default=0, cmdline=False) @@ -38,7 +39,7 @@ def internal(parser: Manager) -> None: parser.add_setting("last_filelist_sorted_order", default=0, cmdline=False) -def identifier(parser: Manager) -> None: +def identifier(parser: settngs.Manager) -> None: # identifier settings parser.add_setting("--series-match-identify-thresh", default=91, type=int, help="") parser.add_setting( @@ -49,7 +50,7 @@ def identifier(parser: Manager) -> None: ) -def dialog(parser: Manager) -> None: +def dialog(parser: settngs.Manager) -> None: # Show/ask dialog flags parser.add_setting("ask_about_cbi_in_rar", default=True, cmdline=False) parser.add_setting("show_disclaimer", default=True, cmdline=False) @@ -57,7 +58,7 @@ def dialog(parser: Manager) -> None: parser.add_setting("ask_about_usage_stats", default=True, cmdline=False) -def filename(parser: Manager) -> None: +def filename(parser: settngs.Manager) -> None: # filename parsing settings parser.add_setting( "--complicated-parser", @@ -85,7 +86,7 @@ def filename(parser: Manager) -> None: ) -def comicvine(parser: Manager) -> None: +def comicvine(parser: settngs.Manager) -> None: # Comic Vine settings parser.add_setting( "--series-match-search-thresh", @@ -145,7 +146,7 @@ def comicvine(parser: Manager) -> None: ) -def cbl(parser: Manager) -> None: +def cbl(parser: settngs.Manager) -> None: # CBL Transform settings parser.add_setting("--assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction) parser.add_setting("--copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction) @@ -158,7 +159,7 @@ def cbl(parser: Manager) -> None: parser.add_setting("--apply-cbl-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction) -def rename(parser: Manager) -> None: +def rename(parser: settngs.Manager) -> None: # Rename settings parser.add_setting("--template", default="{series} #{issue} ({year})", help="The teplate to use when renaming") parser.add_setting( @@ -199,7 +200,7 @@ def rename(parser: Manager) -> None: ) -def autotag(parser: Manager) -> None: +def autotag(parser: settngs.Manager) -> None: # Auto-tag stickies parser.add_setting( "--save-on-low-confidence", @@ -238,7 +239,7 @@ def autotag(parser: Manager) -> None: ) -def validate_settings(options: dict[str, dict[str, Any]], parser: Manager) -> dict[str, dict[str, Any]]: +def validate_settings(options: dict[str, dict[str, Any]], parser: settngs.Manager) -> dict[str, dict[str, Any]]: options["identifier"]["publisher_filter"] = [ x.strip() for x in options["identifier"]["publisher_filter"] if x.strip() ] @@ -249,7 +250,7 @@ def validate_settings(options: dict[str, dict[str, Any]], parser: Manager) -> di return options -def register_settings(parser: Manager) -> None: +def register_settings(parser: settngs.Manager) -> None: parser.add_group("general", general, False) parser.add_group("internal", internal, False) parser.add_group("identifier", identifier, False) diff --git a/comictaggerlib/settings/types.py b/comictaggerlib/ctoptions/types.py similarity index 100% rename from comictaggerlib/settings/types.py rename to comictaggerlib/ctoptions/types.py diff --git a/comictaggerlib/fileselectionlist.py b/comictaggerlib/fileselectionlist.py index 7eb0116..5086d05 100644 --- a/comictaggerlib/fileselectionlist.py +++ b/comictaggerlib/fileselectionlist.py @@ -20,11 +20,11 @@ import os import platform from typing import Callable, cast +import settngs from PyQt5 import QtCore, QtWidgets, uic from comicapi import utils from comicapi.comicarchive import ComicArchive -from comictaggerlib import settings from comictaggerlib.graphics import graphics_path from comictaggerlib.optionalmsgdialog import OptionalMessageDialog from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp @@ -59,7 +59,7 @@ class FileSelectionList(QtWidgets.QWidget): def __init__( self, parent: QtWidgets.QWidget, - options: settings.OptionValues, + options: settngs.ConfigValues, dirty_flag_verification: Callable[[str, str], bool], ) -> None: super().__init__(parent) diff --git a/comictaggerlib/gui.py b/comictaggerlib/gui.py index 1212744..979a44e 100644 --- a/comictaggerlib/gui.py +++ b/comictaggerlib/gui.py @@ -7,7 +7,8 @@ import sys import traceback import types -from comictaggerlib import settings +import settngs + from comictaggerlib.graphics import graphics_path from comictalker.talkerbase import ComicTalker @@ -81,12 +82,10 @@ except ImportError as e: qt_available = False -def open_tagger_window( - talker_api: ComicTalker, options: settings.OptionValues, gui_exception: Exception | None -) -> None: +def open_tagger_window(talker_api: ComicTalker, options: settngs.Config, gui_exception: Exception | None) -> None: os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" args = [] - if options["runtime"]["darkmode"]: + if options[0]["runtime"]["darkmode"]: args.extend(["-platform", "windows:darkmode=2"]) args.extend(sys.argv) app = Application(args) @@ -97,7 +96,7 @@ def open_tagger_window( raise SystemExit(1) # needed to catch initial open file events (macOS) - app.openFileRequest.connect(lambda x: options["runtime"]["files"].append(x.toLocalFile())) + app.openFileRequest.connect(lambda x: options[0]["runtime"]["files"].append(x.toLocalFile())) if platform.system() == "Darwin": # Set the MacOS dock icon @@ -125,7 +124,7 @@ def open_tagger_window( QtWidgets.QApplication.processEvents() try: - tagger_window = TaggerWindow(options["runtime"]["files"], options, talker_api) + tagger_window = TaggerWindow(options[0]["runtime"]["files"], options, talker_api) tagger_window.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) tagger_window.show() diff --git a/comictaggerlib/issueidentifier.py b/comictaggerlib/issueidentifier.py index 7b30fb9..368213f 100644 --- a/comictaggerlib/issueidentifier.py +++ b/comictaggerlib/issueidentifier.py @@ -20,13 +20,13 @@ import logging import sys from typing import Any, Callable +import settngs from typing_extensions import NotRequired, TypedDict from comicapi import utils from comicapi.comicarchive import ComicArchive from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString -from comictaggerlib import settings from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException from comictaggerlib.imagehasher import ImageHasher from comictaggerlib.resulttypes import IssueResult @@ -72,7 +72,7 @@ class IssueIdentifier: result_one_good_match = 4 result_multiple_good_matches = 5 - def __init__(self, comic_archive: ComicArchive, options: settings.OptionValues, talker_api: ComicTalker) -> None: + def __init__(self, comic_archive: ComicArchive, options: settngs.ConfigValues, talker_api: ComicTalker) -> None: self.options = options self.talker_api = talker_api self.comic_archive: ComicArchive = comic_archive diff --git a/comictaggerlib/issueselectionwindow.py b/comictaggerlib/issueselectionwindow.py index 0c5694d..ca03b26 100644 --- a/comictaggerlib/issueselectionwindow.py +++ b/comictaggerlib/issueselectionwindow.py @@ -17,10 +17,10 @@ from __future__ import annotations import logging +import settngs from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi.issuestring import IssueString -from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.ui import ui_path from comictaggerlib.ui.qtutils import reduce_widget_font_size @@ -44,7 +44,7 @@ class IssueSelectionWindow(QtWidgets.QDialog): def __init__( self, parent: QtWidgets.QWidget, - options: settings.OptionValues, + options: settngs.ConfigValues, talker_api: ComicTalker, series_id: int, issue_number: str, diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 01a62c3..509656b 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -24,9 +24,11 @@ import signal import sys from typing import Any +import settngs + import comictalker.comictalkerapi as ct_api from comicapi import utils -from comictaggerlib import cli, settings +from comictaggerlib import cli, ctoptions from comictaggerlib.ctversion import version from comictaggerlib.log import setup_logging from comictalker.talkerbase import TalkerError @@ -63,8 +65,8 @@ class App: """docstring for App""" def __init__(self) -> None: - self.options: dict[str, dict[str, Any]] = {} - self.initial_arg_parser = settings.initial_cmd_line_parser() + self.options = settngs.Config({}, {}) + self.initial_arg_parser = ctoptions.initial_cmd_line_parser() def run(self) -> None: opts = self.initialize() @@ -81,29 +83,33 @@ class App: return opts def register_options(self) -> None: - self.manager = settings.Manager( + self.manager = settngs.Manager( """A utility for reading and writing metadata to comic archives.\n\n\nIf no options are given, %(prog)s will run in windowed mode.""", "For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki", ) - settings.register_commandline(self.manager) - settings.register_settings(self.manager) + ctoptions.register_commandline(self.manager) + ctoptions.register_settings(self.manager) - def parse_options(self, config_paths: settings.ComicTaggerPaths) -> None: - options = self.manager.parse_options(config_paths.user_config_dir / "settings.json") - self.options = settings.validate_commandline_options(options, self.manager) - self.options = settings.validate_settings(options, self.manager) + def parse_options(self, config_paths: ctoptions.ComicTaggerPaths) -> None: + config, success = self.manager.parse_config(config_paths.user_config_dir / "settings.json") + options, definitions = config + if not success: + raise SystemExit(99) + options = ctoptions.validate_commandline_options(options, self.manager) + options = ctoptions.validate_settings(options, self.manager) + self.options = settngs.Config(options, definitions) def initialize_dirs(self) -> None: - self.options["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) - self.options["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) - self.options["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) - self.options["runtime"]["config"].user_state_dir.mkdir(parents=True, exist_ok=True) - self.options["runtime"]["config"].user_log_dir.mkdir(parents=True, exist_ok=True) - logger.debug("user_data_dir: %s", self.options["runtime"]["config"].user_data_dir) - logger.debug("user_config_dir: %s", self.options["runtime"]["config"].user_config_dir) - logger.debug("user_cache_dir: %s", self.options["runtime"]["config"].user_cache_dir) - logger.debug("user_state_dir: %s", self.options["runtime"]["config"].user_state_dir) - logger.debug("user_log_dir: %s", self.options["runtime"]["config"].user_log_dir) + self.options[0]["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) + self.options[0]["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) + self.options[0]["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) + self.options[0]["runtime"]["config"].user_state_dir.mkdir(parents=True, exist_ok=True) + self.options[0]["runtime"]["config"].user_log_dir.mkdir(parents=True, exist_ok=True) + logger.debug("user_data_dir: %s", self.options[0]["runtime"]["config"].user_data_dir) + logger.debug("user_config_dir: %s", self.options[0]["runtime"]["config"].user_config_dir) + logger.debug("user_cache_dir: %s", self.options[0]["runtime"]["config"].user_cache_dir) + logger.debug("user_state_dir: %s", self.options[0]["runtime"]["config"].user_state_dir) + logger.debug("user_log_dir: %s", self.options[0]["runtime"]["config"].user_log_dir) def ctmain(self) -> None: assert self.options is not None @@ -111,10 +117,11 @@ class App: # manage the CV API key # None comparison is used so that the empty string can unset the value - if self.options["comicvine"]["cv_api_key"] is not None or self.options["comicvine"]["cv_url"] is not None: - self.manager.save_file(self.options, self.options["runtime"]["config"].user_config_dir / "settings.json") - logger.debug(pprint.pformat(self.options)) - if self.options["commands"]["only_set_cv_key"]: + if self.options[0]["comicvine"]["cv_api_key"] is not None or self.options[0]["comicvine"]["cv_url"] is not None: + settings_path = self.options[0]["runtime"]["config"].user_config_dir / "settings.json" + self.manager.save_file(self.options[0], settings_path) + logger.debug(pprint.pformat(self.options[0])) + if self.options[0]["commands"]["only_set_cv_key"]: print("Key set") # noqa: T201 return @@ -132,33 +139,33 @@ class App: logger.debug("%s\t%s", pkg.metadata["Name"], pkg.metadata["Version"]) utils.load_publishers() - update_publishers(self.options) + update_publishers(self.options[0]) - if not qt_available and not self.options["runtime"]["no_gui"]: - self.options["runtime"]["no_gui"] = True + if not qt_available and not self.options[0]["runtime"]["no_gui"]: + self.options[0]["runtime"]["no_gui"] = True logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.") gui_exception = None try: talker_api = ct_api.get_comic_talker("comicvine")( # type: ignore[call-arg] version=version, - cache_folder=self.options["runtime"]["config"].user_cache_dir, - series_match_thresh=self.options["comicvine"]["series_match_search_thresh"], - remove_html_tables=self.options["comicvine"]["remove_html_tables"], - use_series_start_as_volume=self.options["comicvine"]["use_series_start_as_volume"], - wait_on_ratelimit=self.options["autotag"]["wait_and_retry_on_rate_limit"], - api_url=self.options["comicvine"]["cv_url"], - api_key=self.options["comicvine"]["cv_api_key"], + cache_folder=self.options[0]["runtime"]["config"].user_cache_dir, + series_match_thresh=self.options[0]["comicvine"]["series_match_search_thresh"], + remove_html_tables=self.options[0]["comicvine"]["remove_html_tables"], + use_series_start_as_volume=self.options[0]["comicvine"]["use_series_start_as_volume"], + wait_on_ratelimit=self.options[0]["autotag"]["wait_and_retry_on_rate_limit"], + api_url=self.options[0]["comicvine"]["cv_url"], + api_key=self.options[0]["comicvine"]["cv_api_key"], ) except TalkerError as e: logger.exception("Unable to load talker") gui_exception = e - if self.options["runtime"]["no_gui"]: + if self.options[0]["runtime"]["no_gui"]: raise SystemExit(1) - if self.options["runtime"]["no_gui"]: + if self.options[0]["runtime"]["no_gui"]: try: - cli.CLI(self.options, talker_api).run() + cli.CLI(self.options[0], talker_api).run() except Exception: logger.exception("CLI mode failed") else: diff --git a/comictaggerlib/matchselectionwindow.py b/comictaggerlib/matchselectionwindow.py index 588fd2c..db93736 100644 --- a/comictaggerlib/matchselectionwindow.py +++ b/comictaggerlib/matchselectionwindow.py @@ -18,10 +18,10 @@ from __future__ import annotations import logging import os +import settngs from PyQt5 import QtCore, QtWidgets, uic from comicapi.comicarchive import ComicArchive -from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.resulttypes import IssueResult from comictaggerlib.ui import ui_path @@ -39,7 +39,7 @@ class MatchSelectionWindow(QtWidgets.QDialog): parent: QtWidgets.QWidget, matches: list[IssueResult], comic_archive: ComicArchive, - options: settings.OptionValues, + options: settngs.Values, talker_api: ComicTalker, ) -> None: super().__init__(parent) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index a3f3d78..5006769 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -17,12 +17,12 @@ from __future__ import annotations import logging +import settngs from PyQt5 import QtCore, QtWidgets, uic from comicapi import utils from comicapi.comicarchive import ComicArchive, MetaDataStyle from comicapi.genericmetadata import GenericMetadata -from comictaggerlib import settings from comictaggerlib.filerenamer import FileRenamer, get_rename_dir from comictaggerlib.settingswindow import SettingsWindow from comictaggerlib.ui import ui_path @@ -38,7 +38,7 @@ class RenameWindow(QtWidgets.QDialog): parent: QtWidgets.QWidget, comic_archive_list: list[ComicArchive], data_style: int, - options: settings.OptionValues, + options: settngs.Config, talker_api: ComicTalker, ) -> None: super().__init__(parent) @@ -61,19 +61,19 @@ class RenameWindow(QtWidgets.QDialog): self.rename_list: list[str] = [] self.btnSettings.clicked.connect(self.modify_settings) - platform = "universal" if self.options["filename"]["rename_strict"] else "auto" - self.renamer = FileRenamer(None, platform=platform, replacements=self.options["rename"]["replacements"]) + platform = "universal" if self.options[0]["filename"]["rename_strict"] else "auto" + self.renamer = FileRenamer(None, platform=platform, replacements=self.options[0]["rename"]["replacements"]) self.do_preview() def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str: - self.renamer.set_template(self.options["filename"]["rename_template"]) - self.renamer.set_issue_zero_padding(self.options["filename"]["rename_issue_number_padding"]) - self.renamer.set_smart_cleanup(self.options["filename"]["rename_use_smart_string_cleanup"]) - self.renamer.replacements = self.options["rename"]["replacements"] + self.renamer.set_template(self.options[0]["filename"]["rename_template"]) + self.renamer.set_issue_zero_padding(self.options[0]["filename"]["rename_issue_number_padding"]) + self.renamer.set_smart_cleanup(self.options[0]["filename"]["rename_use_smart_string_cleanup"]) + self.renamer.replacements = self.options[0]["rename"]["replacements"] new_ext = ca.path.suffix # default - if self.options["filename"]["rename_set_extension_based_on_archive"]: + if self.options[0]["filename"]["rename_set_extension_based_on_archive"]: if ca.is_sevenzip(): new_ext = ".cb7" elif ca.is_zip(): @@ -85,13 +85,13 @@ class RenameWindow(QtWidgets.QDialog): md = ca.read_metadata(self.data_style) if md.is_empty: md = ca.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], + self.options[0]["filename"]["complicated_parser"], + self.options[0]["filename"]["remove_c2c"], + self.options[0]["filename"]["remove_fcbd"], + self.options[0]["filename"]["remove_publisher"], ) self.renamer.set_metadata(md) - self.renamer.move = self.options["filename"]["rename_move_to_dir"] + self.renamer.move = self.options[0]["filename"]["rename_move_to_dir"] return new_ext def do_preview(self) -> None: @@ -104,7 +104,7 @@ class RenameWindow(QtWidgets.QDialog): try: new_name = self.renamer.determine_name(new_ext) except ValueError as e: - logger.exception("Invalid format string: %s", self.options["filename"]["rename_template"]) + logger.exception("Invalid format string: %s", self.options[0]["filename"]["rename_template"]) QtWidgets.QMessageBox.critical( self, "Invalid format string!", @@ -119,7 +119,7 @@ class RenameWindow(QtWidgets.QDialog): except Exception as e: logger.exception( "Formatter failure: %s metadata: %s", - self.options["filename"]["rename_template"], + self.options[0]["filename"]["rename_template"], self.renamer.metadata, ) QtWidgets.QMessageBox.critical( @@ -197,7 +197,9 @@ class RenameWindow(QtWidgets.QDialog): folder = get_rename_dir( comic[0], - self.options["filename"]["rename_dir"] if self.options["filename"]["rename_move_to_dir"] else None, + self.options[0]["filename"]["rename_dir"] + if self.options[0]["filename"]["rename_move_to_dir"] + else None, ) full_path = folder / comic[1] diff --git a/comictaggerlib/settings/__init__.py b/comictaggerlib/settings/__init__.py deleted file mode 100644 index c4c4a90..0000000 --- a/comictaggerlib/settings/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from comictaggerlib.settings.cmdline import initial_cmd_line_parser, register_commandline, validate_commandline_options -from comictaggerlib.settings.file import register_settings, validate_settings -from comictaggerlib.settings.manager import Manager, OptionDefinitions, OptionValues, defaults, save_file -from comictaggerlib.settings.types import ComicTaggerPaths - -__all__ = [ - "initial_cmd_line_parser", - "register_commandline", - "register_settings", - "validate_commandline_options", - "validate_settings", - "Manager", - "ComicTaggerPaths", - "OptionValues", - "OptionDefinitions", - "save_file", - "defaults", -] diff --git a/comictaggerlib/settings/manager.py b/comictaggerlib/settings/manager.py deleted file mode 100644 index fa34606..0000000 --- a/comictaggerlib/settings/manager.py +++ /dev/null @@ -1,401 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import logging -import pathlib -from collections import defaultdict -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union - -logger = logging.getLogger(__name__) - - -class Setting: - def __init__( - self, - # From argparse - *names: str, - action: type[argparse.Action] | None = None, - nargs: str | int | None = None, - const: str | None = None, - default: str | None = None, - type: Callable[..., Any] | None = None, # noqa: A002 - choices: Sequence[Any] | None = None, - required: bool | None = None, - help: str | None = None, # noqa: A002 - metavar: str | None = None, - dest: str | None = None, - # ComicTagger - cmdline: bool = True, - file: bool = True, - group: str = "", - exclusive: bool = False, - ): - if not names: - raise ValueError("names must be specified") - # We prefix the destination name used by argparse so that there are no conflicts - # Argument names will still cause an exception if there is a conflict e.g. if '-f' is defined twice - self.internal_name, dest, flag = self.get_dest(group, names, dest) - args: Sequence[str] = names - - # We then also set the metavar so that '--config' in the group runtime shows as 'CONFIG' instead of 'RUNTIME_CONFIG' - if not metavar and action not in ("store_true", "store_false", "count"): - metavar = dest.upper() - - # If we are not a flag, no '--' or '-' in front - # we prefix the first name with the group as argparse sets dest to args[0] - # I believe internal name may be able to be used here - if not flag: - args = tuple((f"{group}_{names[0]}".lstrip("_"), *names[1:])) - - self.action = action - self.nargs = nargs - self.const = const - self.default = default - self.type = type - self.choices = choices - self.required = required - self.help = help - self.metavar = metavar - self.dest = dest - self.cmdline = cmdline - self.file = file - self.argparse_args = args - self.group = group - self.exclusive = exclusive - - self.argparse_kwargs = { - "action": action, - "nargs": nargs, - "const": const, - "default": default, - "type": type, - "choices": choices, - "required": required, - "help": help, - "metavar": metavar, - "dest": self.internal_name if flag else None, - } - - def __str__(self) -> str: - return f"Setting({self.argparse_args}, type={self.type}, file={self.file}, cmdline={self.cmdline}, kwargs={self.argparse_kwargs})" - - def __repr__(self) -> str: - return self.__str__() - - def get_dest(self, prefix: str, names: Sequence[str], dest: str | None) -> tuple[str, str, bool]: - dest_name = None - flag = False - - for n in names: - if n.startswith("--"): - flag = True - dest_name = n.lstrip("-").replace("-", "_") - break - if n.startswith("-"): - flag = True - - if dest_name is None: - dest_name = names[0] - if dest: - dest_name = dest - if dest_name is None: - raise Exception("Something failed, try again") - - internal_name = f"{prefix}_{dest_name}".lstrip("_") - return internal_name, dest_name, flag - - def filter_argparse_kwargs(self) -> dict[str, Any]: - return {k: v for k, v in self.argparse_kwargs.items() if v is not None} - - def to_argparse(self) -> tuple[Sequence[str], dict[str, Any]]: - return self.argparse_args, self.filter_argparse_kwargs() - - -OptionValues = dict[str, dict[str, Any]] -OptionDefinitions = dict[str, dict[str, Setting]] -if TYPE_CHECKING: - ArgParser = Union[argparse._MutuallyExclusiveGroup, argparse._ArgumentGroup, argparse.ArgumentParser] - - -def get_option(options: OptionValues | argparse.Namespace, setting: Setting) -> tuple[Any, bool]: - """ - Helper function to retrieve the value for a setting and if the value is the default value - - Args: - options: Dictionary or namespace of options - setting: The setting object describing the value to retrieve - """ - if isinstance(options, dict): - value = options.get(setting.group, {}).get(setting.dest, setting.default) - else: - value = getattr(options, setting.internal_name, setting.default) - return value, value == setting.default - - -def normalize_options( - raw_options: OptionValues | argparse.Namespace, - definitions: OptionDefinitions, - file: bool = False, - cmdline: bool = False, - defaults: bool = True, - raw_options_2: OptionValues | argparse.Namespace | None = None, -) -> OptionValues: - """ - Creates an `OptionValues` dictionary with setting definitions taken from `self.definitions` - and values taken from `raw_options` and `raw_options_2' if defined. - Values are assigned so if the value is a dictionary mutating it will mutate the original. - - Args: - raw_options: The dict or Namespace to normalize options from - definitions: The definition of the options - file: Include file options - cmdline: Include cmdline options - defaults: Include default values in the returned dict - raw_options_2: If set, merges non-default values into the returned dict - """ - options: OptionValues = {} - for group_name, group in definitions.items(): - group_options = {} - for setting_name, setting in group.items(): - if (setting.cmdline and cmdline) or (setting.file and file): - # Ensures the option exists with the default if not already set - value, default = get_option(raw_options, setting) - if not default or default and defaults: - group_options[setting_name] = value - - # will override with option from raw_options_2 if it is not the default - if raw_options_2 is not None: - value, default = get_option(raw_options_2, setting) - if not default: - group_options[setting_name] = value - options[group_name] = group_options - options["definitions"] = definitions - return options - - -def parse_file(filename: pathlib.Path, definitions: OptionDefinitions) -> OptionValues: - """ - Helper function to read options from a json dictionary from a file - Args: - filename: A pathlib.Path object to read a json dictionary from - """ - options: OptionValues = {} - if filename.exists(): - try: - with filename.open() as file: - opts = json.load(file) - if isinstance(opts, dict): - options = opts - except Exception: - logger.exception("Failed to load config file: %s", filename) - else: - logger.info("No config file found") - - return normalize_options(options, definitions, file=True) - - -def clean_options( - options: OptionValues | argparse.Namespace, definitions: OptionDefinitions, file: bool = False, cmdline: bool = True -) -> OptionValues: - """ - Normalizes options and then cleans up empty groups and removes 'definitions' - Args: - options: - file: - cmdline: - - Returns: - - """ - - clean_options = normalize_options(options, definitions, file=file, cmdline=cmdline) - del clean_options["definitions"] - for group in list(clean_options.keys()): - if not clean_options[group]: - del clean_options[group] - return clean_options - - -def defaults(definitions: OptionDefinitions) -> OptionValues: - return normalize_options({}, definitions, file=True, cmdline=True) - - -def get_namespace(options: OptionValues, definitions: OptionDefinitions, defaults: bool = True) -> argparse.Namespace: - """ - Returns an argparse.Namespace object with options in the form "{group_name}_{setting_name}" - `options` should already be normalized. - Throws an exception if the internal_name is duplicated - - Args: - options: Normalized options to turn into a Namespace - defaults: Include default values in the returned dict - """ - options = normalize_options(options, definitions, file=True, cmdline=True) - namespace = argparse.Namespace() - for group_name, group in definitions.items(): - for setting_name, setting in group.items(): - if hasattr(namespace, setting.internal_name): - raise Exception(f"Duplicate internal name: {setting.internal_name}") - value, default = get_option(options, setting) - - if not default or default and defaults: - setattr( - namespace, - setting.internal_name, - value, - ) - setattr(namespace, "definitions", definitions) - return namespace - - -def save_file( - options: OptionValues | argparse.Namespace, definitions: OptionDefinitions, filename: pathlib.Path -) -> bool: - """ - Helper function to save options from a json dictionary to a file - Args: - options: The options to save to a json dictionary - filename: A pathlib.Path object to save the json dictionary to - """ - file_options = clean_options(options, definitions, file=True) - if not filename.exists(): - filename.parent.mkdir(exist_ok=True, parents=True) - filename.touch() - - try: - json_str = json.dumps(file_options, indent=2) - filename.write_text(json_str, encoding="utf-8") - except Exception: - logger.exception("Failed to save config file: %s", filename) - return False - return True - - -class Manager: - """docstring for SettingManager""" - - def __init__( - self, description: str | None = None, epilog: str | None = None, definitions: OptionDefinitions | None = None - ): - # This one is never used, it just makes MyPy happy - self.argparser = argparse.ArgumentParser(description=description, epilog=epilog) - self.description = description - self.epilog = epilog - - self.definitions: OptionDefinitions = defaultdict(lambda: dict()) - if definitions: - self.definitions = definitions - - self.exclusive_group = False - self.current_group_name = "" - - def add_setting(self, *args: Any, **kwargs: Any) -> None: - """Takes passes all arguments through to `Setting`, `group` and `exclusive` are already set""" - setting = Setting(*args, group=self.current_group_name, exclusive=self.exclusive_group, **kwargs) - self.definitions[self.current_group_name][setting.dest] = setting - - def create_argparser(self) -> None: - """Creates an :class:`argparse.ArgumentParser` from all cmdline settings""" - groups: dict[str, ArgParser] = {} - self.argparser = argparse.ArgumentParser( - description=self.description, - epilog=self.epilog, - formatter_class=argparse.RawTextHelpFormatter, - ) - for group_name, group in self.definitions.items(): - for setting_name, setting in group.items(): - if setting.cmdline: - argparse_args, argparse_kwargs = setting.to_argparse() - current_group: ArgParser = self.argparser - if setting.group: - if setting.group not in groups: - if setting.exclusive: - groups[setting.group] = self.argparser.add_argument_group( - setting.group, - ).add_mutually_exclusive_group() - else: - groups[setting.group] = self.argparser.add_argument_group(setting.group) - - # hard coded exception for files - if not (setting.group == "runtime" and setting.nargs == "*"): - current_group = groups[setting.group] - current_group.add_argument(*argparse_args, **argparse_kwargs) - - def add_group(self, name: str, add_settings: Callable[[Manager], None], exclusive_group: bool = False) -> None: - """ - The primary way to add define options on this class - - Args: - name: The name of the group to define - add_settings: A function that registers individual options using :meth:`add_setting` - exclusive_group: If this group is an argparse exclusive group - """ - self.current_group_name = name - self.exclusive_group = exclusive_group - add_settings(self) - self.current_group_name = "" - self.exclusive_group = False - - def exit(self, *args: Any, **kwargs: Any) -> NoReturn: - """See :class:`~argparse.ArgumentParser`""" - self.argparser.exit(*args, **kwargs) - raise SystemExit(99) - - def defaults(self) -> OptionValues: - return defaults(self.definitions) - - def clean_options( - self, options: OptionValues | argparse.Namespace, file: bool = False, cmdline: bool = True - ) -> OptionValues: - return clean_options(options=options, definitions=self.definitions, file=file, cmdline=cmdline) - - def normalize_options( - self, - raw_options: OptionValues | argparse.Namespace, - file: bool = False, - cmdline: bool = False, - defaults: bool = True, - raw_options_2: OptionValues | argparse.Namespace | None = None, - ) -> OptionValues: - return normalize_options( - raw_options=raw_options, - definitions=self.definitions, - file=file, - cmdline=cmdline, - defaults=defaults, - raw_options_2=raw_options_2, - ) - - def get_namespace(self, options: OptionValues, defaults: bool = True) -> argparse.Namespace: - return get_namespace(options=options, definitions=self.definitions, defaults=defaults) - - def parse_file(self, filename: pathlib.Path) -> OptionValues: - return parse_file(filename=filename, definitions=self.definitions) - - def save_file(self, options: OptionValues | argparse.Namespace, filename: pathlib.Path) -> bool: - return save_file(options=options, definitions=self.definitions, filename=filename) - - def parse_args(self, args: list[str] | None = None, namespace: argparse.Namespace | None = None) -> OptionValues: - """ - Creates an `argparse.ArgumentParser` from cmdline settings in `self.definitions`. - `args` and `namespace` are passed to `argparse.ArgumentParser.parse_args` - - Args: - args: Passed to argparse.ArgumentParser.parse - namespace: Passed to argparse.ArgumentParser.parse - """ - self.create_argparser() - ns = self.argparser.parse_args(args, namespace=namespace) - - return normalize_options(ns, definitions=self.definitions, cmdline=True, file=True) - - def parse_options(self, config_path: pathlib.Path, args: list[str] | None = None) -> OptionValues: - file_options = self.parse_file( - config_path, - ) - cli_options = self.parse_args(args, self.get_namespace(file_options, defaults=False)) - - final_options = normalize_options(cli_options, definitions=self.definitions, file=True, cmdline=True) - return final_options diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index cb164c4..9e5fa3f 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -22,11 +22,11 @@ import pathlib import platform from typing import Any +import settngs from PyQt5 import QtCore, QtGui, QtWidgets, uic from comicapi import utils from comicapi.genericmetadata import md_test -from comictaggerlib import settings from comictaggerlib.ctversion import version from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements from comictaggerlib.imagefetcher import ImageFetcher @@ -130,7 +130,7 @@ Spider-Geddon #1 - New Players; Check In class SettingsWindow(QtWidgets.QDialog): - def __init__(self, parent: QtWidgets.QWidget, options: settings.OptionValues, talker_api: ComicTalker) -> None: + def __init__(self, parent: QtWidgets.QWidget, options: settngs.Config, talker_api: ComicTalker) -> None: super().__init__(parent) uic.loadUi(ui_path / "settingswindow.ui", self) @@ -269,52 +269,54 @@ class SettingsWindow(QtWidgets.QDialog): def settings_to_form(self) -> None: # Copy values from settings to form - self.leRarExePath.setText(self.options["general"]["rar_exe_path"]) - self.sbNameMatchIdentifyThresh.setValue(self.options["identifier"]["series_match_identify_thresh"]) - self.sbNameMatchSearchThresh.setValue(self.options["comicvine"]["series_match_search_thresh"]) - self.tePublisherFilter.setPlainText("\n".join(self.options["identifier"]["publisher_filter"])) + self.leRarExePath.setText(self.options[0]["general"]["rar_exe_path"]) + self.sbNameMatchIdentifyThresh.setValue(self.options[0]["identifier"]["series_match_identify_thresh"]) + self.sbNameMatchSearchThresh.setValue(self.options[0]["comicvine"]["series_match_search_thresh"]) + self.tePublisherFilter.setPlainText("\n".join(self.options[0]["identifier"]["publisher_filter"])) - self.cbxCheckForNewVersion.setChecked(self.options["general"]["check_for_new_version"]) + self.cbxCheckForNewVersion.setChecked(self.options[0]["general"]["check_for_new_version"]) - self.cbxComplicatedParser.setChecked(self.options["filename"]["complicated_parser"]) - self.cbxRemoveC2C.setChecked(self.options["filename"]["remove_c2c"]) - self.cbxRemoveFCBD.setChecked(self.options["filename"]["remove_fcbd"]) - self.cbxRemovePublisher.setChecked(self.options["filename"]["remove_publisher"]) + self.cbxComplicatedParser.setChecked(self.options[0]["filename"]["complicated_parser"]) + self.cbxRemoveC2C.setChecked(self.options[0]["filename"]["remove_c2c"]) + self.cbxRemoveFCBD.setChecked(self.options[0]["filename"]["remove_fcbd"]) + self.cbxRemovePublisher.setChecked(self.options[0]["filename"]["remove_publisher"]) self.switch_parser() - self.cbxUseSeriesStartAsVolume.setChecked(self.options["comicvine"]["use_series_start_as_volume"]) - self.cbxClearFormBeforePopulating.setChecked(self.options["comicvine"]["clear_form_before_populating_from_cv"]) - self.cbxRemoveHtmlTables.setChecked(self.options["comicvine"]["remove_html_tables"]) + self.cbxUseSeriesStartAsVolume.setChecked(self.options[0]["comicvine"]["use_series_start_as_volume"]) + self.cbxClearFormBeforePopulating.setChecked( + self.options[0]["comicvine"]["clear_form_before_populating_from_cv"] + ) + self.cbxRemoveHtmlTables.setChecked(self.options[0]["comicvine"]["remove_html_tables"]) - self.cbxUseFilter.setChecked(self.options["comicvine"]["always_use_publisher_filter"]) - self.cbxSortByYear.setChecked(self.options["comicvine"]["sort_series_by_year"]) - self.cbxExactMatches.setChecked(self.options["comicvine"]["exact_series_matches_first"]) + self.cbxUseFilter.setChecked(self.options[0]["comicvine"]["always_use_publisher_filter"]) + self.cbxSortByYear.setChecked(self.options[0]["comicvine"]["sort_series_by_year"]) + self.cbxExactMatches.setChecked(self.options[0]["comicvine"]["exact_series_matches_first"]) - self.leKey.setText(self.options["comicvine"]["cv_api_key"]) - self.leURL.setText(self.options["comicvine"]["cv_url"]) + self.leKey.setText(self.options[0]["comicvine"]["cv_api_key"]) + self.leURL.setText(self.options[0]["comicvine"]["cv_url"]) - self.cbxAssumeLoneCreditIsPrimary.setChecked(self.options["cbl"]["assume_lone_credit_is_primary"]) - self.cbxCopyCharactersToTags.setChecked(self.options["cbl"]["copy_characters_to_tags"]) - self.cbxCopyTeamsToTags.setChecked(self.options["cbl"]["copy_teams_to_tags"]) - self.cbxCopyLocationsToTags.setChecked(self.options["cbl"]["copy_locations_to_tags"]) - self.cbxCopyStoryArcsToTags.setChecked(self.options["cbl"]["copy_storyarcs_to_tags"]) - self.cbxCopyNotesToComments.setChecked(self.options["cbl"]["copy_notes_to_comments"]) - self.cbxCopyWebLinkToComments.setChecked(self.options["cbl"]["copy_weblink_to_comments"]) - self.cbxApplyCBLTransformOnCVIMport.setChecked(self.options["cbl"]["apply_cbl_transform_on_cv_import"]) + self.cbxAssumeLoneCreditIsPrimary.setChecked(self.options[0]["cbl"]["assume_lone_credit_is_primary"]) + self.cbxCopyCharactersToTags.setChecked(self.options[0]["cbl"]["copy_characters_to_tags"]) + self.cbxCopyTeamsToTags.setChecked(self.options[0]["cbl"]["copy_teams_to_tags"]) + self.cbxCopyLocationsToTags.setChecked(self.options[0]["cbl"]["copy_locations_to_tags"]) + self.cbxCopyStoryArcsToTags.setChecked(self.options[0]["cbl"]["copy_storyarcs_to_tags"]) + self.cbxCopyNotesToComments.setChecked(self.options[0]["cbl"]["copy_notes_to_comments"]) + self.cbxCopyWebLinkToComments.setChecked(self.options[0]["cbl"]["copy_weblink_to_comments"]) + self.cbxApplyCBLTransformOnCVIMport.setChecked(self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"]) self.cbxApplyCBLTransformOnBatchOperation.setChecked( - self.options["cbl"]["apply_cbl_transform_on_bulk_operation"] + self.options[0]["cbl"]["apply_cbl_transform_on_bulk_operation"] ) - self.leRenameTemplate.setText(self.options["rename"]["template"]) - self.leIssueNumPadding.setText(str(self.options["rename"]["issue_number_padding"])) - self.cbxSmartCleanup.setChecked(self.options["rename"]["use_smart_string_cleanup"]) - self.cbxChangeExtension.setChecked(self.options["rename"]["set_extension_based_on_archive"]) - self.cbxMoveFiles.setChecked(self.options["rename"]["move_to_dir"]) - self.leDirectory.setText(self.options["rename"]["dir"]) - self.cbxRenameStrict.setChecked(self.options["rename"]["strict"]) + self.leRenameTemplate.setText(self.options[0]["rename"]["template"]) + self.leIssueNumPadding.setText(str(self.options[0]["rename"]["issue_number_padding"])) + self.cbxSmartCleanup.setChecked(self.options[0]["rename"]["use_smart_string_cleanup"]) + self.cbxChangeExtension.setChecked(self.options[0]["rename"]["set_extension_based_on_archive"]) + self.cbxMoveFiles.setChecked(self.options[0]["rename"]["move_to_dir"]) + self.leDirectory.setText(self.options[0]["rename"]["dir"]) + self.cbxRenameStrict.setChecked(self.options[0]["rename"]["strict"]) for table, replacments in zip( - (self.twLiteralReplacements, self.twValueReplacements), self.options["rename"]["replacements"] + (self.twLiteralReplacements, self.twValueReplacements), self.options[0]["rename"]["replacements"] ): table.clearContents() for i in reversed(range(table.rowCount())): @@ -349,7 +351,7 @@ class SettingsWindow(QtWidgets.QDialog): self.rename_test() if self.rename_error is not None: if isinstance(self.rename_error, ValueError): - logger.exception("Invalid format string: %s", self.options["rename"]["template"]) + logger.exception("Invalid format string: %s", self.options[0]["rename"]["template"]) QtWidgets.QMessageBox.critical( self, "Invalid format string!", @@ -363,7 +365,7 @@ class SettingsWindow(QtWidgets.QDialog): return else: logger.exception( - "Formatter failure: %s metadata: %s", self.options["rename"]["template"], self.renamer.metadata + "Formatter failure: %s metadata: %s", self.options[0]["rename"]["template"], self.renamer.metadata ) QtWidgets.QMessageBox.critical( self, @@ -376,72 +378,70 @@ class SettingsWindow(QtWidgets.QDialog): ) # Copy values from form to settings and save - self.options["general"]["rar_exe_path"] = str(self.leRarExePath.text()) + self.options[0]["general"]["rar_exe_path"] = str(self.leRarExePath.text()) # make sure rar program is now in the path for the rar class - if self.options["general"]["rar_exe_path"]: - utils.add_to_path(os.path.dirname(self.options["general"]["rar_exe_path"])) + if self.options[0]["general"]["rar_exe_path"]: + utils.add_to_path(os.path.dirname(self.options[0]["general"]["rar_exe_path"])) if not str(self.leIssueNumPadding.text()).isdigit(): self.leIssueNumPadding.setText("0") - self.options["general"]["check_for_new_version"] = self.cbxCheckForNewVersion.isChecked() + self.options[0]["general"]["check_for_new_version"] = self.cbxCheckForNewVersion.isChecked() - self.options["identifier"]["series_match_identify_thresh"] = self.sbNameMatchIdentifyThresh.value() - self.options["comicvine"]["series_match_search_thresh"] = self.sbNameMatchSearchThresh.value() - self.options["identifier"]["publisher_filter"] = [ + self.options[0]["identifier"]["series_match_identify_thresh"] = self.sbNameMatchIdentifyThresh.value() + self.options[0]["comicvine"]["series_match_search_thresh"] = self.sbNameMatchSearchThresh.value() + self.options[0]["identifier"]["publisher_filter"] = [ x.strip() for x in str(self.tePublisherFilter.toPlainText()).splitlines() if x.strip() ] - self.options["filename"]["complicated_parser"] = self.cbxComplicatedParser.isChecked() - self.options["filename"]["remove_c2c"] = self.cbxRemoveC2C.isChecked() - self.options["filename"]["remove_fcbd"] = self.cbxRemoveFCBD.isChecked() - self.options["filename"]["remove_publisher"] = self.cbxRemovePublisher.isChecked() + self.options[0]["filename"]["complicated_parser"] = self.cbxComplicatedParser.isChecked() + self.options[0]["filename"]["remove_c2c"] = self.cbxRemoveC2C.isChecked() + self.options[0]["filename"]["remove_fcbd"] = self.cbxRemoveFCBD.isChecked() + self.options[0]["filename"]["remove_publisher"] = self.cbxRemovePublisher.isChecked() - self.options["comicvine"]["use_series_start_as_volume"] = self.cbxUseSeriesStartAsVolume.isChecked() - self.options["comicvine"][ + self.options[0]["comicvine"]["use_series_start_as_volume"] = self.cbxUseSeriesStartAsVolume.isChecked() + self.options[0]["comicvine"][ "clear_form_before_populating_from_cv" ] = self.cbxClearFormBeforePopulating.isChecked() - self.options["comicvine"]["remove_html_tables"] = self.cbxRemoveHtmlTables.isChecked() + self.options[0]["comicvine"]["remove_html_tables"] = self.cbxRemoveHtmlTables.isChecked() - self.options["comicvine"]["always_use_publisher_filter"] = self.cbxUseFilter.isChecked() - self.options["comicvine"]["sort_series_by_year"] = self.cbxSortByYear.isChecked() - self.options["comicvine"]["exact_series_matches_first"] = self.cbxExactMatches.isChecked() + self.options[0]["comicvine"]["always_use_publisher_filter"] = self.cbxUseFilter.isChecked() + self.options[0]["comicvine"]["sort_series_by_year"] = self.cbxSortByYear.isChecked() + self.options[0]["comicvine"]["exact_series_matches_first"] = self.cbxExactMatches.isChecked() if self.leKey.text().strip(): - self.options["comicvine"]["cv_api_key"] = self.leKey.text().strip() - self.talker_api.api_key = self.options["comicvine"]["cv_api_key"] + self.options[0]["comicvine"]["cv_api_key"] = self.leKey.text().strip() + self.talker_api.api_key = self.options[0]["comicvine"]["cv_api_key"] if self.leURL.text().strip(): - self.options["comicvine"]["cv_url"] = self.leURL.text().strip() - self.talker_api.api_url = self.options["comicvine"]["cv_url"] + self.options[0]["comicvine"]["cv_url"] = self.leURL.text().strip() + self.talker_api.api_url = self.options[0]["comicvine"]["cv_url"] - self.options["cbl"]["assume_lone_credit_is_primary"] = self.cbxAssumeLoneCreditIsPrimary.isChecked() - self.options["cbl"]["copy_characters_to_tags"] = self.cbxCopyCharactersToTags.isChecked() - self.options["cbl"]["copy_teams_to_tags"] = self.cbxCopyTeamsToTags.isChecked() - self.options["cbl"]["copy_locations_to_tags"] = self.cbxCopyLocationsToTags.isChecked() - self.options["cbl"]["copy_storyarcs_to_tags"] = self.cbxCopyStoryArcsToTags.isChecked() - self.options["cbl"]["copy_notes_to_comments"] = self.cbxCopyNotesToComments.isChecked() - self.options["cbl"]["copy_weblink_to_comments"] = self.cbxCopyWebLinkToComments.isChecked() - self.options["cbl"]["apply_cbl_transform_on_cv_import"] = self.cbxApplyCBLTransformOnCVIMport.isChecked() - self.options["cbl"][ + self.options[0]["cbl"]["assume_lone_credit_is_primary"] = self.cbxAssumeLoneCreditIsPrimary.isChecked() + self.options[0]["cbl"]["copy_characters_to_tags"] = self.cbxCopyCharactersToTags.isChecked() + self.options[0]["cbl"]["copy_teams_to_tags"] = self.cbxCopyTeamsToTags.isChecked() + self.options[0]["cbl"]["copy_locations_to_tags"] = self.cbxCopyLocationsToTags.isChecked() + self.options[0]["cbl"]["copy_storyarcs_to_tags"] = self.cbxCopyStoryArcsToTags.isChecked() + self.options[0]["cbl"]["copy_notes_to_comments"] = self.cbxCopyNotesToComments.isChecked() + self.options[0]["cbl"]["copy_weblink_to_comments"] = self.cbxCopyWebLinkToComments.isChecked() + self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"] = self.cbxApplyCBLTransformOnCVIMport.isChecked() + self.options[0]["cbl"][ "apply_cbl_transform_on_bulk_operation" ] = self.cbxApplyCBLTransformOnBatchOperation.isChecked() - self.options["rename"]["template"] = str(self.leRenameTemplate.text()) - self.options["rename"]["issue_number_padding"] = int(self.leIssueNumPadding.text()) - self.options["rename"]["use_smart_string_cleanup"] = self.cbxSmartCleanup.isChecked() - self.options["rename"]["set_extension_based_on_archive"] = self.cbxChangeExtension.isChecked() - self.options["rename"]["move_to_dir"] = self.cbxMoveFiles.isChecked() - self.options["rename"]["dir"] = self.leDirectory.text() + self.options[0]["rename"]["template"] = str(self.leRenameTemplate.text()) + self.options[0]["rename"]["issue_number_padding"] = int(self.leIssueNumPadding.text()) + self.options[0]["rename"]["use_smart_string_cleanup"] = self.cbxSmartCleanup.isChecked() + self.options[0]["rename"]["set_extension_based_on_archive"] = self.cbxChangeExtension.isChecked() + self.options[0]["rename"]["move_to_dir"] = self.cbxMoveFiles.isChecked() + self.options[0]["rename"]["dir"] = self.leDirectory.text() - self.options["rename"]["strict"] = self.cbxRenameStrict.isChecked() - self.options["rename"]["replacements"] = self.get_replacemnts() + self.options[0]["rename"]["strict"] = self.cbxRenameStrict.isChecked() + self.options[0]["rename"]["replacements"] = self.get_replacemnts() - settings.save_file( - self.options, - self.options["definitions"], - self.options["runtime"]["config"].user_config_dir / "settings.json", + settngs.save_file( + self.options[0], self.options[1], self.options[0]["runtime"]["config"].user_config_dir / "settings.json" ) self.parent().options = self.options QtWidgets.QDialog.accept(self) @@ -450,8 +450,8 @@ class SettingsWindow(QtWidgets.QDialog): self.select_file(self.leRarExePath, "RAR") def clear_cache(self) -> None: - ImageFetcher(self.options["runtime"]["config"].cache_folder).clear_cache() - ComicCacher(self.options["runtime"]["config"].cache_folder, version).clear_cache() + ImageFetcher(self.options[0]["runtime"]["config"].cache_folder).clear_cache() + ComicCacher(self.options[0]["runtime"]["config"].cache_folder, version).clear_cache() QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.") def test_api_key(self) -> None: @@ -461,7 +461,7 @@ class SettingsWindow(QtWidgets.QDialog): QtWidgets.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.") def reset_settings(self) -> None: - self.options = settings.defaults(self.options["definitions"]) + self.options = settngs.Config(settngs.defaults(self.options[1]), self.options[1]) self.settings_to_form() QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.") diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index 3ec4f5a..e8c7c61 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -31,6 +31,7 @@ from typing import Any, Callable from urllib.parse import urlparse import natsort +import settngs from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets, uic from comicapi import utils @@ -39,7 +40,7 @@ from comicapi.comicinfoxml import ComicInfoXml from comicapi.filenameparser import FileNameParser from comicapi.genericmetadata import GenericMetadata from comicapi.issuestring import IssueString -from comictaggerlib import ctversion, settings +from comictaggerlib import ctversion from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger from comictaggerlib.autotagmatchwindow import AutoTagMatchWindow from comictaggerlib.autotagprogresswindow import AutoTagProgressWindow @@ -78,7 +79,7 @@ class TaggerWindow(QtWidgets.QMainWindow): def __init__( self, file_list: list[str], - options: settings.OptionValues, + options: settngs.Config, talker_api: ComicTalker, parent: QtWidgets.QWidget | None = None, ) -> None: @@ -87,17 +88,17 @@ class TaggerWindow(QtWidgets.QMainWindow): uic.loadUi(ui_path / "taggerwindow.ui", self) self.options = options if not options: - self.options = {} + self.options = ({}, {}) self.talker_api = talker_api self.log_window = self.setup_logger() # prevent multiple instances socket = QtNetwork.QLocalSocket(self) - socket.connectToServer(options["internal"]["install_id"]) + socket.connectToServer(options[0]["internal"]["install_id"]) alive = socket.waitForConnected(3000) if alive: logger.setLevel(logging.INFO) - logger.info("Another application with key [%s] is already running", options["internal"]["install_id"]) + logger.info("Another application with key [%s] is already running", options[0]["internal"]["install_id"]) # send file list to other instance if file_list: socket.write(pickle.dumps(file_list)) @@ -109,15 +110,15 @@ class TaggerWindow(QtWidgets.QMainWindow): # listen on a socket to prevent multiple instances self.socketServer = QtNetwork.QLocalServer(self) self.socketServer.newConnection.connect(self.on_incoming_socket_connection) - ok = self.socketServer.listen(options["internal"]["install_id"]) + ok = self.socketServer.listen(options[0]["internal"]["install_id"]) if not ok: if self.socketServer.serverError() == QtNetwork.QAbstractSocket.SocketError.AddressInUseError: - self.socketServer.removeServer(options["internal"]["install_id"]) - ok = self.socketServer.listen(options["internal"]["install_id"]) + self.socketServer.removeServer(options[0]["internal"]["install_id"]) + ok = self.socketServer.listen(options[0]["internal"]["install_id"]) if not ok: logger.error( "Cannot start local socket with key [%s]. Reason: %s", - options["internal"]["install_id"], + options[0]["internal"]["install_id"], self.socketServer.errorString(), ) sys.exit() @@ -131,15 +132,15 @@ class TaggerWindow(QtWidgets.QMainWindow): grid_layout = QtWidgets.QGridLayout(self.tabPages) grid_layout.addWidget(self.page_list_editor) - self.fileSelectionList = FileSelectionList(self.widgetListHolder, self.options, self.dirty_flag_verification) + self.fileSelectionList = FileSelectionList(self.widgetListHolder, self.options[0], self.dirty_flag_verification) grid_layout = QtWidgets.QGridLayout(self.widgetListHolder) grid_layout.addWidget(self.fileSelectionList) self.fileSelectionList.selectionChanged.connect(self.file_list_selection_changed) self.fileSelectionList.listCleared.connect(self.file_list_cleared) self.fileSelectionList.set_sorting( - self.options["internal"]["last_filelist_sorted_column"], - QtCore.Qt.SortOrder(self.options["internal"]["last_filelist_sorted_order"]), + self.options[0]["internal"]["last_filelist_sorted_column"], + QtCore.Qt.SortOrder(self.options[0]["internal"]["last_filelist_sorted_order"]), ) # we can't specify relative font sizes in the UI designer, so @@ -157,13 +158,13 @@ class TaggerWindow(QtWidgets.QMainWindow): self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) - if options["runtime"]["type"] and isinstance(options["runtime"]["type"][0], int): + if options[0]["runtime"]["type"] and isinstance(options[0]["runtime"]["type"][0], int): # respect the command line option tag type - options["internal"]["last_selected_save_data_style"] = options["runtime"]["type"][0] - options["internal"]["last_selected_load_data_style"] = options["runtime"]["type"][0] + options[0]["internal"]["last_selected_save_data_style"] = options[0]["runtime"]["type"][0] + options[0]["internal"]["last_selected_load_data_style"] = options[0]["runtime"]["type"][0] - self.save_data_style = options["internal"]["last_selected_save_data_style"] - self.load_data_style = options["internal"]["last_selected_load_data_style"] + self.save_data_style = options[0]["internal"]["last_selected_save_data_style"] + self.load_data_style = options[0]["internal"]["last_selected_load_data_style"] self.setAcceptDrops(True) self.config_menus() @@ -228,9 +229,12 @@ class TaggerWindow(QtWidgets.QMainWindow): self.show() self.set_app_position() - if self.options["internal"]["last_form_side_width"] != -1: + if self.options[0]["internal"]["last_form_side_width"] != -1: self.splitter.setSizes( - [self.options["internal"]["last_form_side_width"], self.options["internal"]["last_list_side_width"]] + [ + self.options[0]["internal"]["last_form_side_width"], + self.options[0]["internal"]["last_list_side_width"], + ] ) self.raise_() QtCore.QCoreApplication.processEvents() @@ -248,7 +252,7 @@ class TaggerWindow(QtWidgets.QMainWindow): if len(file_list) != 0: self.fileSelectionList.add_path_list(file_list) - if self.options["dialog"]["show_disclaimer"]: + if self.options[0]["dialog"]["show_disclaimer"]: checked = OptionalMessageDialog.msg( self, "Welcome!", @@ -263,9 +267,9 @@ use ComicTagger on local copies of your comics.

Have fun! """, ) - self.options["dialog"]["show_disclaimer"] = not checked + self.options[0]["dialog"]["show_disclaimer"] = not checked - if self.options["general"]["check_for_new_version"]: + if self.options[0]["general"]["check_for_new_version"]: self.check_latest_version_online() def open_file_event(self, url: QtCore.QUrl) -> None: @@ -278,7 +282,7 @@ Have fun! def setup_logger(self) -> ApplicationLogWindow: try: - current_logs = (self.options["runtime"]["config"].user_log_dir / "ComicTagger.log").read_text("utf-8") + current_logs = (self.options[0]["runtime"]["config"].user_log_dir / "ComicTagger.log").read_text("utf-8") except Exception: current_logs = "" root_logger = logging.getLogger() @@ -611,10 +615,10 @@ Have fun! def actual_load_current_archive(self) -> None: if self.metadata.is_empty and self.comic_archive is not None: self.metadata = self.comic_archive.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], + self.options[0]["filename"]["complicated_parser"], + self.options[0]["filename"]["remove_c2c"], + self.options[0]["filename"]["remove_fcbd"], + self.options[0]["filename"]["remove_publisher"], ) if len(self.metadata.pages) == 0 and self.comic_archive is not None: self.metadata.set_default_page_list(self.comic_archive.get_number_of_pages()) @@ -982,10 +986,10 @@ Have fun! # copy the form onto metadata object self.form_to_metadata() new_metadata = self.comic_archive.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], + self.options[0]["filename"]["complicated_parser"], + self.options[0]["filename"]["remove_c2c"], + self.options[0]["filename"]["remove_fcbd"], + self.options[0]["filename"]["remove_publisher"], split_words, ) if new_metadata is not None: @@ -1006,8 +1010,8 @@ Have fun! else: dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFiles) - if self.options["internal"]["last_opened_folder"] is not None: - dialog.setDirectory(self.options["internal"]["last_opened_folder"]) + if self.options[0]["internal"]["last_opened_folder"] is not None: + dialog.setDirectory(self.options[0]["internal"]["last_opened_folder"]) if not folder_mode: archive_filter = "Comic archive files (*.cbz *.zip *.cbr *.rar *.cb7 *.7z)" @@ -1057,7 +1061,7 @@ Have fun! issue_count, cover_index_list, self.comic_archive, - self.options, + self.options[0], self.talker_api, autoselect, literal, @@ -1089,10 +1093,10 @@ Have fun! else: QtWidgets.QApplication.restoreOverrideCursor() if new_metadata is not None: - if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: - new_metadata = CBLTransformer(new_metadata, self.options).apply() + if self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"]: + new_metadata = CBLTransformer(new_metadata, self.options[0]).apply() - if self.options["comicvine"]["clear_form_before_populating_from_cv"]: + if self.options[0]["comicvine"]["clear_form_before_populating_from_cv"]: self.clear_form() notes = ( @@ -1147,7 +1151,7 @@ Have fun! "Change Tag Read Style", "If you change read tag style now, data in the form will be lost. Are you sure?" ): self.load_data_style = self.cbLoadDataStyle.itemData(s) - self.options["internal"]["last_selected_load_data_style"] = self.load_data_style + self.options[0]["internal"]["last_selected_load_data_style"] = self.load_data_style self.update_menus() if self.comic_archive is not None: self.load_archive(self.comic_archive) @@ -1158,7 +1162,7 @@ Have fun! def set_save_data_style(self, s: int) -> None: self.save_data_style = self.cbSaveDataStyle.itemData(s) - self.options["internal"]["last_selected_save_data_style"] = self.save_data_style + self.options[0]["internal"]["last_selected_save_data_style"] = self.save_data_style self.update_style_tweaks() self.update_menus() @@ -1383,10 +1387,13 @@ Have fun! settingswin.result() def set_app_position(self) -> None: - if self.options["internal"]["last_main_window_width"] != 0: - self.move(self.options["internal"]["last_main_window_x"], self.options["internal"]["last_main_window_y"]) + if self.options[0]["internal"]["last_main_window_width"] != 0: + self.move( + self.options[0]["internal"]["last_main_window_x"], self.options[0]["internal"]["last_main_window_y"] + ) self.resize( - self.options["internal"]["last_main_window_width"], self.options["internal"]["last_main_window_height"] + self.options[0]["internal"]["last_main_window_width"], + self.options[0]["internal"]["last_main_window_height"], ) else: screen = QtGui.QGuiApplication.primaryScreen().geometry() @@ -1656,9 +1663,9 @@ Have fun! if ( dest_style == MetaDataStyle.CBI - and self.options["cbl"]["apply_cbl_transform_on_bulk_operation"] + and self.options[0]["cbl"]["apply_cbl_transform_on_bulk_operation"] ): - md = CBLTransformer(md, self.options).apply() + md = CBLTransformer(md, self.options[0]).apply() if not ca.write_metadata(md, dest_style): failed_list.append(ca.path) @@ -1696,8 +1703,8 @@ Have fun! logger.exception("Save aborted.") if not ct_md.is_empty: - if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: - ct_md = CBLTransformer(ct_md, self.options).apply() + if self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"]: + ct_md = CBLTransformer(ct_md, self.options[0]).apply() QtWidgets.QApplication.restoreOverrideCursor() @@ -1716,7 +1723,7 @@ Have fun! self, ca: ComicArchive, match_results: OnlineMatchResults, dlg: AutoTagStartWindow ) -> tuple[bool, OnlineMatchResults]: success = False - ii = IssueIdentifier(ca, self.options, self.talker_api) + ii = IssueIdentifier(ca, self.options[0], self.talker_api) # read in metadata, and parse file name if not there try: @@ -1726,10 +1733,10 @@ Have fun! logger.error("Failed to load metadata for %s: %s", ca.path, e) if md.is_empty: md = ca.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], + self.options[0]["filename"]["complicated_parser"], + self.options[0]["filename"]["remove_c2c"], + self.options[0]["filename"]["remove_fcbd"], + self.options[0]["filename"]["remove_publisher"], dlg.split_words, ) if dlg.ignore_leading_digits_in_filename and md.series is not None: @@ -1815,7 +1822,7 @@ Have fun! ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if self.options["comicvine"]["auto_imprint"]: + if self.options[0]["comicvine"]["auto_imprint"]: md.fix_publisher() if not ca.write_metadata(md, self.save_data_style): @@ -1844,7 +1851,7 @@ Have fun! atstartdlg = AutoTagStartWindow( self, - self.options, + self.options[0], ( f"You have selected {len(ca_list)} archive(s) to automatically identify and write {MetaDataStyle.name[style]} tags to." "\n\nPlease choose options below, and select OK to Auto-Tag." @@ -1947,7 +1954,7 @@ Have fun! match_results.multiple_matches, style, self.actual_issue_data_fetch, - self.options, + self.options[0], self.talker_api, ) matchdlg.setModal(True) @@ -1993,20 +2000,18 @@ Have fun! f"Exit {self.appName}", "If you quit now, data in the form will be lost. Are you sure?" ): appsize = self.size() - self.options["internal"]["last_main_window_width"] = appsize.width() - self.options["internal"]["last_main_window_height"] = appsize.height() - self.options["internal"]["last_main_window_x"] = self.x() - self.options["internal"]["last_main_window_y"] = self.y() - self.options["internal"]["last_form_side_width"] = self.splitter.sizes()[0] - self.options["internal"]["last_list_side_width"] = self.splitter.sizes()[1] + self.options[0]["internal"]["last_main_window_width"] = appsize.width() + self.options[0]["internal"]["last_main_window_height"] = appsize.height() + self.options[0]["internal"]["last_main_window_x"] = self.x() + self.options[0]["internal"]["last_main_window_y"] = self.y() + self.options[0]["internal"]["last_form_side_width"] = self.splitter.sizes()[0] + self.options[0]["internal"]["last_list_side_width"] = self.splitter.sizes()[1] ( - self.options["internal"]["last_filelist_sorted_column"], - self.options["internal"]["last_filelist_sorted_order"], + self.options[0]["internal"]["last_filelist_sorted_column"], + self.options[0]["internal"]["last_filelist_sorted_order"], ) = self.fileSelectionList.get_sorting() - settings.save_file( - self.options, - self.options["definitions"], - self.options["runtime"]["config"].user_config_dir / "settings.json", + settngs.save_file( + self.options[0], self.options[1], self.options[0]["runtime"]["config"].user_config_dir / "settings.json" ) event.accept() @@ -2056,7 +2061,7 @@ Have fun! def apply_cbl_transform(self) -> None: self.form_to_metadata() - self.metadata = CBLTransformer(self.metadata, self.options).apply() + self.metadata = CBLTransformer(self.metadata, self.options[0]).apply() self.metadata_to_form() def recalc_page_dimensions(self) -> None: @@ -2101,7 +2106,7 @@ Have fun! QtCore.QTimer.singleShot(1, self.fileSelectionList.revert_selection) return - self.options["internal"]["last_opened_folder"] = os.path.abspath(os.path.split(comic_archive.path)[0]) + self.options[0]["internal"]["last_opened_folder"] = os.path.abspath(os.path.split(comic_archive.path)[0]) self.comic_archive = comic_archive try: self.metadata = self.comic_archive.read_metadata(self.load_data_style) @@ -2134,12 +2139,12 @@ Have fun! version_checker = VersionChecker() self.version_check_complete( version_checker.get_latest_version( - self.options["internal"]["install_id"], self.options["general"]["send_usage_stats"] + self.options[0]["internal"]["install_id"], self.options[0]["general"]["send_usage_stats"] ) ) def version_check_complete(self, new_version: tuple[str, str]) -> None: - if new_version[0] not in (self.version, self.options["dialog"]["dont_notify_about_this_version"]): + if new_version[0] not in (self.version, self.options[0]["dialog"]["dont_notify_about_this_version"]): website = "https://github.com/comictagger/comictagger" checked = OptionalMessageDialog.msg( self, @@ -2150,7 +2155,7 @@ Have fun! "Don't tell me about this version again", ) if checked: - self.options["dialog"]["dont_notify_about_this_version"] = new_version[0] + self.options[0]["dialog"]["dont_notify_about_this_version"] = new_version[0] def on_incoming_socket_connection(self) -> None: # Accept connection from other instance. diff --git a/comictaggerlib/volumeselectionwindow.py b/comictaggerlib/volumeselectionwindow.py index 03ff093..87c901f 100644 --- a/comictaggerlib/volumeselectionwindow.py +++ b/comictaggerlib/volumeselectionwindow.py @@ -19,13 +19,13 @@ import itertools import logging from collections import deque +import settngs from PyQt5 import QtCore, QtGui, QtWidgets, uic from PyQt5.QtCore import pyqtSignal from comicapi import utils from comicapi.comicarchive import ComicArchive from comicapi.genericmetadata import GenericMetadata -from comictaggerlib import settings from comictaggerlib.coverimagewidget import CoverImageWidget from comictaggerlib.issueidentifier import IssueIdentifier from comictaggerlib.issueselectionwindow import IssueSelectionWindow @@ -111,7 +111,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): issue_count: int, cover_index_list: list[int], comic_archive: ComicArchive | None, - options: settings.OptionValues, + options: settngs.ConfigValues, talker_api: ComicTalker, autoselect: bool = False, literal: bool = False, diff --git a/requirements.txt b/requirements.txt index 972345f..f36c9c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ pycountry pyicu; sys_platform == 'linux' or sys_platform == 'darwin' rapidfuzz>=2.12.0 requests==2.* +settngs==0.2.0 text2digits typing_extensions wordninja diff --git a/testing/settings.py b/testing/settings.py deleted file mode 100644 index dc0fe31..0000000 --- a/testing/settings.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -settings_cases = [ - ( - ( - ("--test",), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="tst", - exclusive=False, - ), - ), # Equivalent to Setting("--test", group="tst") - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", # dest is calculated by Setting and is not used by argparse - "exclusive": False, - "file": True, - "group": "tst", - "help": None, - "internal_name": "tst_test", # Should almost always be "{group}_{dest}" - "metavar": "TEST", # Set manually so argparse doesn't use TST_TEST - "nargs": None, - "required": None, - "type": None, - "argparse_args": ("--test",), # *args actually sent to argparse - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": "tst_test", - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, # Non-None **kwargs sent to argparse - }, - ), - ( - ( - ( - "-t", - "--test", - ), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="tst", - exclusive=False, - ), - ), # Equivalent to Setting("-t", "--test", group="tst") - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "tst", - "help": None, - "internal_name": "tst_test", - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ( - "-t", - "--test", - ), # Only difference with above is here - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": "tst_test", - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), - ( - ( - ("test",), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="tst", - exclusive=False, - ), - ), # Equivalent to Setting("test", group="tst") - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "tst", - "help": None, - "internal_name": "tst_test", - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ("tst_test",), - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": None, # Only difference with #1 is here, argparse sets dest based on the *args passed to it - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), - ( - ( - ("--test",), - dict( - action=None, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=None, - help=None, - metavar=None, - dest=None, - cmdline=True, - file=True, - group="", - exclusive=False, - ), - ), # Equivalent to Setting("test") - { - "action": None, - "choices": None, - "cmdline": True, - "const": None, - "default": None, - "dest": "test", - "exclusive": False, - "file": True, - "group": "", - "help": None, - "internal_name": "test", # No group, leading _ is stripped - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - "argparse_args": ("--test",), - "argparse_kwargs": { - "action": None, - "choices": None, - "const": None, - "default": None, - "dest": "test", - "help": None, - "metavar": "TEST", - "nargs": None, - "required": None, - "type": None, - }, - }, - ), -] diff --git a/tests/comiccacher_test.py b/tests/comiccacher_test.py index 547f699..a9f87c2 100644 --- a/tests/comiccacher_test.py +++ b/tests/comiccacher_test.py @@ -6,9 +6,9 @@ import comictalker.comiccacher from testing.comicdata import search_results -def test_create_cache(settings, mock_version): - comictalker.comiccacher.ComicCacher(settings["runtime"]["config"].user_cache_dir, mock_version[0]) - assert (settings["runtime"]["config"].user_cache_dir).exists() +def test_create_cache(options, mock_version): + comictalker.comiccacher.ComicCacher(options["runtime"]["config"].user_cache_dir, mock_version[0]) + assert (options["runtime"]["config"].user_cache_dir).exists() def test_search_results(comic_cache): diff --git a/tests/conftest.py b/tests/conftest.py index bde5491..b5dc79b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,15 +9,15 @@ from typing import Any import pytest import requests +import settngs from PIL import Image import comicapi.comicarchive import comicapi.genericmetadata -import comictaggerlib.settings +import comictaggerlib.ctoptions import comictalker.comiccacher import comictalker.talkers.comicvine from comicapi import utils -from comictaggerlib import settings as ctsettings from testing import comicvine, filenames from testing.comicdata import all_seed_imprints, seed_imprints @@ -56,7 +56,7 @@ def no_requests(monkeypatch) -> None: @pytest.fixture def comicvine_api( - monkeypatch, cbz, comic_cache, mock_version, settings + monkeypatch, cbz, comic_cache, mock_version, options ) -> comictalker.talkers.comicvine.ComicVineTalker: # Any arguments may be passed and mock_get() will always return our # mocked object, which only has the .json() method or None for invalid urls. @@ -117,7 +117,7 @@ def comicvine_api( cv = comictalker.talkers.comicvine.ComicVineTalker( version=mock_version[0], - cache_folder=settings["runtime"]["config"].user_cache_dir, + cache_folder=options["runtime"]["config"].user_cache_dir, api_url="", api_key="", series_match_thresh=90, @@ -163,12 +163,12 @@ def seed_all_publishers(monkeypatch): @pytest.fixture -def settings(settings_manager, tmp_path): +def options(settings_manager, tmp_path): - ctsettings.register_commandline(settings_manager) - ctsettings.register_settings(settings_manager) + comictaggerlib.ctoptions.register_commandline(settings_manager) + comictaggerlib.ctoptions.register_settings(settings_manager) defaults = settings_manager.defaults() - defaults["runtime"]["config"] = ctsettings.ComicTaggerPaths(tmp_path / "config") + defaults["runtime"]["config"] = comictaggerlib.ctoptions.ComicTaggerPaths(tmp_path / "config") defaults["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) defaults["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) defaults["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) @@ -179,10 +179,10 @@ def settings(settings_manager, tmp_path): @pytest.fixture def settings_manager(): - manager = ctsettings.Manager() + manager = settngs.Manager() yield manager @pytest.fixture -def comic_cache(settings, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]: - yield comictalker.comiccacher.ComicCacher(settings["runtime"]["config"].user_cache_dir, mock_version[0]) +def comic_cache(options, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]: + yield comictalker.comiccacher.ComicCacher(options["runtime"]["config"].user_cache_dir, mock_version[0]) diff --git a/tests/issueidentifier_test.py b/tests/issueidentifier_test.py index a4e3498..bf970cb 100644 --- a/tests/issueidentifier_test.py +++ b/tests/issueidentifier_test.py @@ -9,8 +9,8 @@ import testing.comicdata import testing.comicvine -def test_crop(cbz_double_cover, settings, tmp_path, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, settings, comicvine_api) +def test_crop(cbz_double_cover, options, tmp_path, comicvine_api): + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, options, comicvine_api) cropped = ii.crop_cover(cbz_double_cover.archiver.read_file("double_cover.jpg")) original_cover = cbz_double_cover.get_page(0) @@ -21,15 +21,15 @@ def test_crop(cbz_double_cover, settings, tmp_path, comicvine_api): @pytest.mark.parametrize("additional_md, expected", testing.comicdata.metadata_keys) -def test_get_search_keys(cbz, settings, additional_md, expected, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, settings, comicvine_api) +def test_get_search_keys(cbz, options, additional_md, expected, comicvine_api): + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, options, comicvine_api) ii.set_additional_metadata(additional_md) assert expected == ii.get_search_keys() -def test_get_issue_cover_match_score(cbz, settings, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, settings, comicvine_api) +def test_get_issue_cover_match_score(cbz, options, comicvine_api): + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, options, comicvine_api) score = ii.get_issue_cover_match_score( int( comicapi.issuestring.IssueString( @@ -49,8 +49,8 @@ def test_get_issue_cover_match_score(cbz, settings, comicvine_api): assert expected == score -def test_search(cbz, settings, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, settings, comicvine_api) +def test_search(cbz, options, comicvine_api): + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, options, comicvine_api) results = ii.search() cv_expected = { "series": f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})", diff --git a/tests/settings_test.py b/tests/settings_test.py deleted file mode 100644 index 7072555..0000000 --- a/tests/settings_test.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -import argparse -import json - -import pytest - -import comictaggerlib.settings.manager -from testing.settings import settings_cases - - -def test_settings_manager(): - manager = comictaggerlib.settings.manager.Manager() - defaults = manager.defaults() - assert manager is not None and defaults is not None - - -@pytest.mark.parametrize("arguments, expected", settings_cases) -def test_setting(arguments, expected): - assert vars(comictaggerlib.settings.manager.Setting(*arguments[0], **arguments[1])) == expected - - -def test_add_setting(settings_manager): - assert settings_manager.add_setting("--test") is None - - -def test_get_defaults(settings_manager): - settings_manager.add_setting("--test", default="hello") - defaults = settings_manager.defaults() - assert defaults[""]["test"] == "hello" - - -def test_get_namespace(settings_manager): - settings_manager.add_setting("--test", default="hello") - defaults = settings_manager.get_namespace(settings_manager.defaults()) - assert defaults.test == "hello" - - -def test_get_defaults_group(settings_manager): - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) - defaults = settings_manager.defaults() - assert defaults["tst"]["test"] == "hello" - - -def test_get_namespace_group(settings_manager): - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) - defaults = settings_manager.get_namespace(settings_manager.defaults()) - assert defaults.tst_test == "hello" - - -def test_cmdline_only(settings_manager): - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", file=False)) - settings_manager.add_group("tst2", lambda parser: parser.add_setting("--test2", default="hello", cmdline=False)) - - file_normalized = settings_manager.normalize_options({}, file=True) - cmdline_normalized = settings_manager.normalize_options({}, cmdline=True) - - assert "test" in cmdline_normalized["tst"] - assert "test2" not in cmdline_normalized["tst2"] - - assert "test" not in file_normalized["tst"] - assert "test2" in file_normalized["tst2"] - - -def test_normalize(settings_manager): - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) - - defaults = settings_manager.defaults() - defaults["test"] = "fail" # Not defined in settings_manager - - defaults_namespace = settings_manager.get_namespace(defaults) - defaults_namespace.test = "fail" - - normalized = settings_manager.normalize_options(defaults, file=True) - normalized_namespace = settings_manager.get_namespace(settings_manager.normalize_options(defaults, file=True)) - - assert "test" not in normalized - assert "tst" in normalized - assert "test" in normalized["tst"] - assert normalized["tst"]["test"] == "hello" - - assert not hasattr(normalized_namespace, "test") - assert hasattr(normalized_namespace, "tst_test") - assert normalized_namespace.tst_test == "hello" - - -@pytest.mark.parametrize( - "raw, raw2, expected", - [ - ({"tst": {"test": "fail"}}, argparse.Namespace(tst_test="success"), "success"), - # hello is default so is not used in raw_options_2 - ({"tst": {"test": "success"}}, argparse.Namespace(tst_test="hello"), "success"), - (argparse.Namespace(tst_test="fail"), {"tst": {"test": "success"}}, "success"), - (argparse.Namespace(tst_test="success"), {"tst": {"test": "hello"}}, "success"), - ], -) -def test_normalize_merge(raw, raw2, expected, settings_manager): - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) - - normalized = settings_manager.normalize_options(raw, file=True, raw_options_2=raw2) - - assert normalized["tst"]["test"] == expected - - -def test_cli_set(settings_manager, tmp_path): - settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({})) - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", file=False)) - - normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) - - assert "test" in normalized["tst"] - assert normalized["tst"]["test"] == "success" - - -def test_file_set(settings_manager, tmp_path): - settings_file = tmp_path / "settings.json" - settings_file.write_text( - json.dumps( - { - "tst": {"test": "success"}, - } - ) - ) - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello", cmdline=False)) - - normalized = settings_manager.parse_options(settings_file, []) - - assert "test" in normalized["tst"] - assert normalized["tst"]["test"] == "success" - - -def test_cli_override_file(settings_manager, tmp_path): - settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({"tst": {"test": "fail"}})) - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="hello")) - - normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) - - assert "test" in normalized["tst"] - assert normalized["tst"]["test"] == "success" - - -def test_cli_explicit_default(settings_manager, tmp_path): - settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({"tst": {"test": "fail"}})) - settings_manager.add_group("tst", lambda parser: parser.add_setting("--test", default="success")) - - normalized = settings_manager.parse_options(settings_file, ["--test", "success"]) - - assert "test" in normalized["tst"] - assert normalized["tst"]["test"] == "success" From e5c3692bb9a98b035768c43bb004a96e9ec7589b Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Thu, 15 Dec 2022 18:58:53 -0800 Subject: [PATCH 21/22] Fail if an error occurs when loading settings --- comictaggerlib/gui.py | 11 +++++----- comictaggerlib/main.py | 49 +++++++++++++++++++++++------------------- setup.py | 2 +- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/comictaggerlib/gui.py b/comictaggerlib/gui.py index 979a44e..3bad58c 100644 --- a/comictaggerlib/gui.py +++ b/comictaggerlib/gui.py @@ -82,18 +82,17 @@ except ImportError as e: qt_available = False -def open_tagger_window(talker_api: ComicTalker, options: settngs.Config, gui_exception: Exception | None) -> None: +def open_tagger_window(talker_api: ComicTalker, options: settngs.Config, error: tuple[str, bool] | None) -> None: os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" args = [] if options[0]["runtime"]["darkmode"]: args.extend(["-platform", "windows:darkmode=2"]) args.extend(sys.argv) app = Application(args) - if gui_exception is not None: - trace_back = "".join(traceback.format_tb(gui_exception.__traceback__)) - log_msg = f"{type(gui_exception).__name__}: {gui_exception}\n\n{trace_back}" - show_exception_box(f"{log_msg}") - raise SystemExit(1) + if error is not None: + show_exception_box(error[0]) + if error[1]: + raise SystemExit(1) # needed to catch initial open file events (macOS) app.openFileRequest.connect(lambda x: options[0]["runtime"]["files"].append(x.toLocalFile())) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 509656b..6153129 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -19,7 +19,6 @@ import argparse import json import logging.handlers import platform -import pprint import signal import sys from typing import Any @@ -67,6 +66,7 @@ class App: def __init__(self) -> None: self.options = settngs.Config({}, {}) self.initial_arg_parser = ctoptions.initial_cmd_line_parser() + self.config_load_success = False def run(self) -> None: opts = self.initialize() @@ -74,7 +74,7 @@ class App: self.parse_options(opts.config) self.initialize_dirs() - self.ctmain() + self.main() def initialize(self) -> argparse.Namespace: opts, _ = self.initial_arg_parser.parse_known_args() @@ -91,10 +91,8 @@ class App: ctoptions.register_settings(self.manager) def parse_options(self, config_paths: ctoptions.ComicTaggerPaths) -> None: - config, success = self.manager.parse_config(config_paths.user_config_dir / "settings.json") + config, self.config_load_success = self.manager.parse_config(config_paths.user_config_dir / "settings.json") options, definitions = config - if not success: - raise SystemExit(99) options = ctoptions.validate_commandline_options(options, self.manager) options = ctoptions.validate_settings(options, self.manager) self.options = settngs.Config(options, definitions) @@ -111,19 +109,10 @@ class App: logger.debug("user_state_dir: %s", self.options[0]["runtime"]["config"].user_state_dir) logger.debug("user_log_dir: %s", self.options[0]["runtime"]["config"].user_log_dir) - def ctmain(self) -> None: + def main(self) -> None: assert self.options is not None # options already loaded - - # manage the CV API key - # None comparison is used so that the empty string can unset the value - if self.options[0]["comicvine"]["cv_api_key"] is not None or self.options[0]["comicvine"]["cv_url"] is not None: - settings_path = self.options[0]["runtime"]["config"].user_config_dir / "settings.json" - self.manager.save_file(self.options[0], settings_path) - logger.debug(pprint.pformat(self.options[0])) - if self.options[0]["commands"]["only_set_cv_key"]: - print("Key set") # noqa: T201 - return + error = None signal.signal(signal.SIGINT, signal.SIG_DFL) @@ -145,7 +134,18 @@ class App: self.options[0]["runtime"]["no_gui"] = True logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.") - gui_exception = None + # manage the CV API key + # None comparison is used so that the empty string can unset the value + if self.options[0]["comicvine"]["cv_api_key"] is not None or self.options[0]["comicvine"]["cv_url"] is not None: + settings_path = self.options[0]["runtime"]["config"].user_config_dir / "settings.json" + if self.config_load_success: + self.manager.save_file(self.options[0], settings_path) + + if self.options[0]["commands"]["only_set_cv_key"]: + if self.config_load_success: + print("Key set") # noqa: T201 + return + try: talker_api = ct_api.get_comic_talker("comicvine")( # type: ignore[call-arg] version=version, @@ -159,15 +159,20 @@ class App: ) except TalkerError as e: logger.exception("Unable to load talker") - gui_exception = e - if self.options[0]["runtime"]["no_gui"]: - raise SystemExit(1) + error = (str(e), True) + if not self.config_load_success: + error = ( + f"Failed to load settings, check the log located in '{self.options[0]['runtime']['config'].user_log_dir}' for more details", + True, + ) if self.options[0]["runtime"]["no_gui"]: + 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.options[0], talker_api).run() except Exception: logger.exception("CLI mode failed") else: - - gui.open_tagger_window(talker_api, self.options, gui_exception) + gui.open_tagger_window(talker_api, self.options, error) diff --git a/setup.py b/setup.py index a2b00ab..d1cdd48 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ setup( packages=["comictaggerlib", "comicapi"], package_data={"comictaggerlib": ["ui/*", "graphics/*"], "comicapi": ["data/*"]}, entry_points=dict( - console_scripts=["comictagger=comictaggerlib.main:ctmain"], + console_scripts=["comictagger=comictaggerlib.main:main"], pyinstaller40=[ "hook-dirs = comictaggerlib.__pyinstaller:get_hook_dirs", ], From 440479da8c9824b8df1e6e3bf1cc961370bb5f8c Mon Sep 17 00:00:00 2001 From: Timmy Welch Date: Thu, 15 Dec 2022 20:10:35 -0800 Subject: [PATCH 22/22] Update to settngs 0.3.0 Use the namespace instead of a dictionary Cleanup setting names --- comictaggerlib/autotagmatchwindow.py | 15 +-- comictaggerlib/autotagstartwindow.py | 32 ++--- comictaggerlib/cbltransformer.py | 16 +-- comictaggerlib/cli.py | 154 +++++++++++----------- comictaggerlib/ctoptions/cmdline.py | 89 ++++++------- comictaggerlib/ctoptions/file.py | 44 +++---- comictaggerlib/fileselectionlist.py | 9 +- comictaggerlib/gui.py | 10 +- comictaggerlib/issueidentifier.py | 18 +-- comictaggerlib/issueselectionwindow.py | 7 +- comictaggerlib/main.py | 66 +++++----- comictaggerlib/matchselectionwindow.py | 5 +- comictaggerlib/renamewindow.py | 32 +++-- comictaggerlib/settingswindow.py | 164 +++++++++++------------- comictaggerlib/taggerwindow.py | 129 ++++++++----------- comictaggerlib/volumeselectionwindow.py | 18 +-- requirements.txt | 2 +- tests/comiccacher_test.py | 5 +- tests/conftest.py | 18 +-- tests/issueidentifier_test.py | 12 +- 20 files changed, 406 insertions(+), 439 deletions(-) diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py index 59496e4..ea7aaf3 100644 --- a/comictaggerlib/autotagmatchwindow.py +++ b/comictaggerlib/autotagmatchwindow.py @@ -42,7 +42,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog): match_set_list: list[MultipleMatch], style: int, fetch_func: Callable[[IssueResult], GenericMetadata], - options: settngs.ConfigValues, + options: settngs.Namespace, talker_api: ComicTalker, ) -> None: super().__init__(parent) @@ -54,10 +54,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog): self.current_match_set: MultipleMatch = match_set_list[0] self.altCoverWidget = CoverImageWidget( - self.altCoverContainer, - CoverImageWidget.AltCoverMode, - options["runtime"]["config"].user_cache_dir, - talker_api, + self.altCoverContainer, CoverImageWidget.AltCoverMode, options.runtime_config.user_cache_dir, talker_api ) gridlayout = QtWidgets.QGridLayout(self.altCoverContainer) gridlayout.addWidget(self.altCoverWidget) @@ -245,10 +242,10 @@ class AutoTagMatchWindow(QtWidgets.QDialog): md = ca.read_metadata(self._style) if md.is_empty: md = ca.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], + self.options.filename_complicated_parser, + self.options.filename_remove_c2c, + self.options.filename_remove_fcbd, + self.options.filename_remove_publisher, ) # now get the particular issue data diff --git a/comictaggerlib/autotagstartwindow.py b/comictaggerlib/autotagstartwindow.py index 5b8901c..b0ff6b2 100644 --- a/comictaggerlib/autotagstartwindow.py +++ b/comictaggerlib/autotagstartwindow.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) class AutoTagStartWindow(QtWidgets.QDialog): - def __init__(self, parent: QtWidgets.QWidget, options: settngs.ConfigValues, msg: str) -> None: + def __init__(self, parent: QtWidgets.QWidget, options: settngs.Namespace, msg: str) -> None: super().__init__(parent) uic.loadUi(ui_path / "autotagstartwindow.ui", self) @@ -40,16 +40,16 @@ class AutoTagStartWindow(QtWidgets.QDialog): self.cbxSpecifySearchString.setChecked(False) self.cbxSplitWords.setChecked(False) - self.sbNameMatchSearchThresh.setValue(self.options["identifier"]["series_match_identify_thresh"]) + self.sbNameMatchSearchThresh.setValue(self.options.identifier_series_match_identify_thresh) self.leSearchString.setEnabled(False) - self.cbxSaveOnLowConfidence.setChecked(self.options["autotag"]["save_on_low_confidence"]) - self.cbxDontUseYear.setChecked(self.options["autotag"]["dont_use_year_when_identifying"]) - self.cbxAssumeIssueOne.setChecked(self.options["autotag"]["assume_1_if_no_issue_num"]) - self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.options["autotag"]["ignore_leading_numbers_in_filename"]) - self.cbxRemoveAfterSuccess.setChecked(self.options["autotag"]["remove_archive_after_successful_match"]) - self.cbxWaitForRateLimit.setChecked(self.options["autotag"]["wait_and_retry_on_rate_limit"]) - self.cbxAutoImprint.setChecked(self.options["comicvine"]["auto_imprint"]) + self.cbxSaveOnLowConfidence.setChecked(self.options.autotag_save_on_low_confidence) + self.cbxDontUseYear.setChecked(self.options.autotag_dont_use_year_when_identifying) + self.cbxAssumeIssueOne.setChecked(self.options.autotag_assume_1_if_no_issue_num) + self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.options.autotag_ignore_leading_numbers_in_filename) + self.cbxRemoveAfterSuccess.setChecked(self.options.autotag_remove_archive_after_successful_match) + self.cbxWaitForRateLimit.setChecked(self.options.autotag_wait_and_retry_on_rate_limit) + self.cbxAutoImprint.setChecked(self.options.comicvine_auto_imprint) nlmt_tip = """The Name Match Ratio Threshold: Auto-Identify is for eliminating automatic search matches that are too long compared to your series name search. The lower @@ -75,7 +75,7 @@ class AutoTagStartWindow(QtWidgets.QDialog): self.remove_after_success = False self.wait_and_retry_on_rate_limit = False self.search_string = "" - self.name_length_match_tolerance = self.options["comicvine"]["series_match_search_thresh"] + self.name_length_match_tolerance = self.options.comicvine_series_match_search_thresh self.split_words = self.cbxSplitWords.isChecked() def search_string_toggle(self) -> None: @@ -95,12 +95,12 @@ class AutoTagStartWindow(QtWidgets.QDialog): self.split_words = self.cbxSplitWords.isChecked() # persist some settings - self.options["autotag"]["save_on_low_confidence"] = self.auto_save_on_low - self.options["autotag"]["dont_use_year_when_identifying"] = self.dont_use_year - self.options["autotag"]["assume_1_if_no_issue_num"] = self.assume_issue_one - self.options["autotag"]["ignore_leading_numbers_in_filename"] = self.ignore_leading_digits_in_filename - self.options["autotag"]["remove_archive_after_successful_match"] = self.remove_after_success - self.options["autotag"]["wait_and_retry_on_rate_limit"] = self.wait_and_retry_on_rate_limit + self.options.autotag_save_on_low_confidence = self.auto_save_on_low + self.options.autotag_dont_use_year_when_identifying = self.dont_use_year + self.options.autotag_assume_1_if_no_issue_num = self.assume_issue_one + self.options.autotag_ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename + self.options.autotag_remove_archive_after_successful_match = self.remove_after_success + self.options.autotag_wait_and_retry_on_rate_limit = self.wait_and_retry_on_rate_limit if self.cbxSpecifySearchString.isChecked(): self.search_string = self.leSearchString.text() diff --git a/comictaggerlib/cbltransformer.py b/comictaggerlib/cbltransformer.py index ec0e59d..16d5c6f 100644 --- a/comictaggerlib/cbltransformer.py +++ b/comictaggerlib/cbltransformer.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) class CBLTransformer: - def __init__(self, metadata: GenericMetadata, options: settngs.ConfigValues) -> None: + def __init__(self, metadata: GenericMetadata, options: settngs.Namespace) -> None: self.metadata = metadata self.options = options @@ -41,7 +41,7 @@ class CBLTransformer: for item in items: append_to_tags_if_unique(item) - if self.options["cbl"]["assume_lone_credit_is_primary"]: + if self.options.cbl_assume_lone_credit_is_primary: # helper def set_lone_primary(role_list: list[str]) -> tuple[CreditMetadata | None, int]: @@ -68,19 +68,19 @@ class CBLTransformer: c["primary"] = False self.metadata.add_credit(c["person"], "Artist", True) - if self.options["cbl"]["copy_characters_to_tags"]: + if self.options.cbl_copy_characters_to_tags: add_string_list_to_tags(self.metadata.characters) - if self.options["cbl"]["copy_teams_to_tags"]: + if self.options.cbl_copy_teams_to_tags: add_string_list_to_tags(self.metadata.teams) - if self.options["cbl"]["copy_locations_to_tags"]: + if self.options.cbl_copy_locations_to_tags: add_string_list_to_tags(self.metadata.locations) - if self.options["cbl"]["copy_storyarcs_to_tags"]: + if self.options.cbl_copy_storyarcs_to_tags: add_string_list_to_tags(self.metadata.story_arc) - if self.options["cbl"]["copy_notes_to_comments"]: + if self.options.cbl_copy_notes_to_comments: if self.metadata.notes is not None: if self.metadata.comments is None: self.metadata.comments = "" @@ -89,7 +89,7 @@ class CBLTransformer: if self.metadata.notes not in self.metadata.comments: self.metadata.comments += self.metadata.notes - if self.options["cbl"]["copy_weblink_to_comments"]: + if self.options.cbl_copy_weblink_to_comments: if self.metadata.web_link is not None: if self.metadata.comments is None: self.metadata.comments = "" diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index f01dd04..8229dee 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -53,14 +53,14 @@ class CLI: logger.exception(f"Error retrieving issue details. Save aborted.\n{e}") return GenericMetadata() - if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: + if self.options.cbl_apply_transform_on_import: ct_md = CBLTransformer(ct_md, self.options).apply() return ct_md def actual_metadata_save(self, ca: ComicArchive, md: GenericMetadata) -> bool: - if not self.options["runtime"]["dryrun"]: - for metadata_style in self.options["runtime"]["type"]: + if not self.options.runtime_dryrun: + for metadata_style in self.options.runtime_type: # write out the new data if not ca.write_metadata(md, metadata_style): logger.error("The tag save seemed to fail for style: %s!", MetaDataStyle.name[metadata_style]) @@ -69,7 +69,7 @@ class CLI: print("Save complete.") logger.info("Save complete.") else: - if self.options["runtime"]["terse"]: + if self.options.runtime_quiet: logger.info("dry-run option was set, so nothing was written") print("dry-run option was set, so nothing was written") else: @@ -97,7 +97,7 @@ class CLI: m["issue_title"], ) ) - if self.options["runtime"]["interactive"]: + if self.options.runtime_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": @@ -108,7 +108,7 @@ class CLI: ca = match_set.ca md = self.create_local_metadata(ca) ct_md = self.actual_issue_data_fetch(match_set.matches[int(i) - 1]["issue_id"]) - if self.options["comicvine"]["clear_metadata_on_import"]: + if self.options.comicvine_clear_metadata_on_import: md = ct_md else: notes = ( @@ -117,14 +117,14 @@ class CLI: ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if self.options["comicvine"]["auto_imprint"]: + if self.options.comicvine_auto_imprint: md.fix_publisher() self.actual_metadata_save(ca, md) def post_process_matches(self, match_results: OnlineMatchResults) -> None: # now go through the match results - if self.options["runtime"]["show_save_summary"]: + if self.options.runtime_summary: if len(match_results.good_matches) > 0: print("\nSuccessful matches:\n------------------") for f in match_results.good_matches: @@ -145,7 +145,7 @@ class CLI: for f in match_results.fetch_data_failures: print(f) - if not self.options["runtime"]["show_save_summary"] and not self.options["runtime"]["interactive"]: + if not self.options.runtime_summary and not self.options.runtime_interactive: # just quit if we're not interactive or showing the summary return @@ -165,14 +165,14 @@ class CLI: self.display_match_set_for_choice(label, match_set) def run(self) -> None: - if len(self.options["runtime"]["file_list"]) < 1: + if len(self.options.runtime_file_list) < 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.options["runtime"]["file_list"]) > 1 + self.batch_mode = len(self.options.runtime_file_list) > 1 - for f in self.options["runtime"]["file_list"]: + for f in self.options.runtime_file_list: self.process_file_cli(f, match_results) sys.stdout.flush() @@ -183,18 +183,18 @@ class CLI: md.set_default_page_list(ca.get_number_of_pages()) # now, overlay the parsed filename info - if self.options["runtime"]["parse_filename"]: + if self.options.runtime_parse_filename: f_md = ca.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], - self.options["runtime"]["split_words"], + self.options.filename_complicated_parser, + self.options.filename_remove_c2c, + self.options.filename_remove_fcbd, + self.options.filename_remove_publisher, + self.options.runtime_split_words, ) md.overlay(f_md) - for metadata_style in self.options["runtime"]["type"]: + for metadata_style in self.options.runtime_type: if ca.has_metadata(metadata_style): try: t_md = ca.read_metadata(metadata_style) @@ -204,12 +204,12 @@ class CLI: logger.error("Failed to load metadata for %s: %s", ca.path, e) # finally, use explicit stuff - md.overlay(self.options["runtime"]["metadata"]) + md.overlay(self.options.runtime_metadata) return md def print(self, ca: ComicArchive) -> None: - if not self.options["runtime"]["type"]: + if not self.options.runtime_type: page_count = ca.get_number_of_pages() brief = "" @@ -246,38 +246,38 @@ class CLI: print(brief) - if self.options["runtime"]["terse"]: + if self.options.runtime_quiet: return print() - if not self.options["runtime"]["type"] or MetaDataStyle.CIX in self.options["runtime"]["type"]: + if not self.options.runtime_type or MetaDataStyle.CIX in self.options.runtime_type: if ca.has_metadata(MetaDataStyle.CIX): print("--------- ComicRack tags ---------") try: - if self.options["runtime"]["raw"]: + if self.options.runtime_raw: print(ca.read_raw_cix()) else: print(ca.read_cix()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - if not self.options["runtime"]["type"] or MetaDataStyle.CBI in self.options["runtime"]["type"]: + if not self.options.runtime_type or MetaDataStyle.CBI in self.options.runtime_type: if ca.has_metadata(MetaDataStyle.CBI): print("------- ComicBookLover tags -------") try: - if self.options["runtime"]["raw"]: + if self.options.runtime_raw: pprint(json.loads(ca.read_raw_cbi())) else: print(ca.read_cbi()) except Exception as e: logger.error("Failed to load metadata for %s: %s", ca.path, e) - if not self.options["runtime"]["type"] or MetaDataStyle.COMET in self.options["runtime"]["type"]: + if not self.options.runtime_type or MetaDataStyle.COMET in self.options.runtime_type: if ca.has_metadata(MetaDataStyle.COMET): print("----------- CoMet tags -----------") try: - if self.options["runtime"]["raw"]: + if self.options.runtime_raw: print(ca.read_raw_comet()) else: print(ca.read_comet()) @@ -285,10 +285,10 @@ class CLI: logger.error("Failed to load metadata for %s: %s", ca.path, e) def delete(self, ca: ComicArchive) -> None: - for metadata_style in self.options["runtime"]["type"]: + for metadata_style in self.options.runtime_type: style_name = MetaDataStyle.name[metadata_style] if ca.has_metadata(metadata_style): - if not self.options["runtime"]["dryrun"]: + if not self.options.runtime_dryrun: if not ca.remove_metadata(metadata_style): print(f"{ca.path}: Tag removal seemed to fail!") else: @@ -299,25 +299,25 @@ class CLI: print(f"{ca.path}: This archive doesn't have {style_name} tags to remove.") def copy(self, ca: ComicArchive) -> None: - for metadata_style in self.options["runtime"]["type"]: + for metadata_style in self.options.runtime_type: dst_style_name = MetaDataStyle.name[metadata_style] - if self.options["runtime"]["no_overwrite"] and ca.has_metadata(metadata_style): + if not self.options.runtime_overwrite and ca.has_metadata(metadata_style): print(f"{ca.path}: Already has {dst_style_name} tags. Not overwriting.") return - if self.options["commands"]["copy"] == metadata_style: + if self.options.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.options["commands"]["copy"]] - if ca.has_metadata(self.options["commands"]["copy"]): - if not self.options["runtime"]["dryrun"]: + src_style_name = MetaDataStyle.name[self.options.commands_copy] + if ca.has_metadata(self.options.commands_copy): + if not self.options.runtime_dryrun: try: - md = ca.read_metadata(self.options["commands"]["copy"]) + md = ca.read_metadata(self.options.commands_copy) except Exception as e: md = GenericMetadata() logger.error("Failed to load metadata for %s: %s", ca.path, e) - if self.options["apply_cbl_transform_on_bulk_operation"] and metadata_style == MetaDataStyle.CBI: + if self.options.apply_transform_on_bulk_operation_ndetadata_style == MetaDataStyle.CBI: md = CBLTransformer(md, self.options).apply() if not ca.write_metadata(md, metadata_style): @@ -330,8 +330,8 @@ class CLI: print(f"{ca.path}: This archive doesn't have {src_style_name} tags to copy.") def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> None: - if self.options["runtime"]["no_overwrite"]: - for metadata_style in self.options["runtime"]["type"]: + if not self.options.runtime_overwrite: + for metadata_style in self.options.runtime_type: if ca.has_metadata(metadata_style): print(f"{ca.path}: Already has {MetaDataStyle.name[metadata_style]} tags. Not overwriting.") return @@ -341,26 +341,26 @@ class CLI: md = self.create_local_metadata(ca) if md.issue is None or md.issue == "": - if self.options["runtime"]["assume_issue_one"]: + if self.options.runtime_assume_issue_one: md.issue = "1" # now, search online - if self.options["runtime"]["online"]: - if self.options["runtime"]["issue_id"] is not None: + if self.options.runtime_online: + if self.options.runtime_issue_id is not None: # we were given the actual issue ID to search with try: - ct_md = self.talker_api.fetch_comic_data(self.options["runtime"]["issue_id"]) + ct_md = self.talker_api.fetch_comic_data(self.options.runtime_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 if ct_md is None: - logger.error("No match for ID %s was found.", self.options["runtime"]["issue_id"]) + logger.error("No match for ID %s was found.", self.options.runtime_issue_id) match_results.no_matches.append(str(ca.path.absolute())) return - if self.options["cbl"]["apply_cbl_transform_on_cv_import"]: + if self.options.cbl_apply_transform_on_import: ct_md = CBLTransformer(ct_md, self.options).apply() else: if md is None or md.is_empty: @@ -371,7 +371,7 @@ class CLI: ii = IssueIdentifier(ca, self.options, self.talker_api) def myoutput(text: str) -> None: - if self.options["runtime"]["verbose"]: + if self.options.runtime_verbose: IssueIdentifier.default_write_output(text) # use our overlaid MD struct to search @@ -411,7 +411,7 @@ class CLI: logger.error("Online search: Multiple good matches. Save aborted") match_results.multiple_matches.append(MultipleMatch(ca, matches)) return - if low_confidence and self.options["runtime"]["abort_on_low_confidence"]: + if low_confidence and self.options.runtime_abort_on_low_confidence: logger.error("Online search: Low confidence match. Save aborted") match_results.low_confidence_matches.append(MultipleMatch(ca, matches)) return @@ -428,7 +428,7 @@ class CLI: match_results.fetch_data_failures.append(str(ca.path.absolute())) return - if self.options["comicvine"]["clear_metadata_on_import"]: + if self.options.comicvine_clear_metadata_on_import: md = ct_md else: notes = ( @@ -437,7 +437,7 @@ class CLI: ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if self.options["comicvine"]["auto_imprint"]: + if self.options.comicvine_auto_imprint: md.fix_publisher() # ok, done building our metadata. time to save @@ -459,7 +459,7 @@ class CLI: return new_ext = "" # default - if self.options["filename"]["rename_set_extension_based_on_archive"]: + if self.options.filename_rename_set_extension_based_on_archive: if ca.is_sevenzip(): new_ext = ".cb7" elif ca.is_zip(): @@ -469,13 +469,13 @@ class CLI: renamer = FileRenamer( md, - platform="universal" if self.options["filename"]["rename_strict"] else "auto", - replacements=self.options["rename"]["replacements"], + platform="universal" if self.options.filename_rename_strict else "auto", + replacements=self.options.rename_replacements, ) - renamer.set_template(self.options["filename"]["rename_template"]) - renamer.set_issue_zero_padding(self.options["filename"]["rename_issue_number_padding"]) - renamer.set_smart_cleanup(self.options["filename"]["rename_use_smart_string_cleanup"]) - renamer.move = self.options["filename"]["rename_move_to_dir"] + renamer.set_template(self.options.filename_rename_template) + renamer.set_issue_zero_padding(self.options.filename_rename_issue_number_padding) + renamer.set_smart_cleanup(self.options.filename_rename_use_smart_string_cleanup) + renamer.move = self.options.filename_rename_move_to_dir try: new_name = renamer.determine_name(ext=new_ext) @@ -487,16 +487,16 @@ class CLI: "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.options["filename"]["rename_template"], + self.options.filename_rename_template, ) return except Exception: logger.exception( - "Formatter failure: %s metadata: %s", self.options["filename"]["rename_template"], renamer.metadata + "Formatter failure: %s metadata: %s", self.options.filename_rename_template, renamer.metadata ) folder = get_rename_dir( - ca, self.options["filename"]["rename_dir"] if self.options["filename"]["rename_move_to_dir"] else None + ca, self.options.filename_rename_dir if self.options.filename_rename_move_to_dir else None ) full_path = folder / new_name @@ -506,7 +506,7 @@ class CLI: return suffix = "" - if not self.options["runtime"]["dryrun"]: + if not self.options.runtime_dryrun: # rename the file try: ca.rename(utils.unique_file(full_path)) @@ -529,7 +529,7 @@ class CLI: filename_path = ca.path new_file = filename_path.with_suffix(".cbz") - if self.options["runtime"]["abort_on_conflict"] and new_file.exists(): + if self.options.runtime_abort_on_conflict and new_file.exists(): print(msg_hdr + f"{new_file.name} already exists in the that folder.") return @@ -537,10 +537,10 @@ class CLI: delete_success = False export_success = False - if not self.options["runtime"]["dryrun"]: + if not self.options.runtime_dryrun: if ca.export_as_zip(new_file): export_success = True - if self.options["runtime"]["delete_after_zip_export"]: + if self.options.runtime_delete_after_zip_export: try: filename_path.unlink(missing_ok=True) delete_success = True @@ -552,7 +552,7 @@ class CLI: new_file.unlink(missing_ok=True) else: msg = msg_hdr + f"Dry-run: Would try to create {os.path.split(new_file)[1]}" - if self.options["runtime"]["delete_after_zip_export"]: + if self.options.runtime_delete_after_zip_export: msg += " and delete original." print(msg) return @@ -560,7 +560,7 @@ class CLI: msg = msg_hdr if export_success: msg += f"Archive exported successfully to: {os.path.split(new_file)[1]}" - if self.options["runtime"]["delete_after_zip_export"] and delete_success: + if self.options.runtime_delete_after_zip_export and delete_success: msg += " (Original deleted) " else: msg += "Archive failed to export!" @@ -572,35 +572,35 @@ class CLI: logger.error("Cannot find %s", filename) return - ca = ComicArchive(filename, self.options["general"]["rar_exe_path"], str(graphics_path / "nocover.png")) + ca = ComicArchive(filename, self.options.general_rar_exe_path, 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 if not ca.is_writable() and ( - self.options["commands"]["delete"] - or self.options["commands"]["copy"] - or self.options["commands"]["save"] - or self.options["commands"]["rename"] + self.options.commands_delete + or self.options.commands_copy + or self.options.commands_save + or self.options.commands_rename ): logger.error("This archive is not writable") return - if self.options["commands"]["print"]: + if self.options.commands_print: self.print(ca) - elif self.options["commands"]["delete"]: + elif self.options.commands_delete: self.delete(ca) - elif self.options["commands"]["copy"] is not None: + elif self.options.commands_copy is not None: self.copy(ca) - elif self.options["commands"]["save"]: + elif self.options.commands_save: self.save(ca, match_results) - elif self.options["commands"]["rename"]: + elif self.options.commands_rename: self.rename(ca) - elif self.options["commands"]["export_to_zip"]: + elif self.options.commands_export_to_zip: self.export(ca) diff --git a/comictaggerlib/ctoptions/cmdline.py b/comictaggerlib/ctoptions/cmdline.py index 087f972..f0c1c36 100644 --- a/comictaggerlib/ctoptions/cmdline.py +++ b/comictaggerlib/ctoptions/cmdline.py @@ -116,17 +116,18 @@ def register_options(parser: settngs.Manager) -> None: file=False, ) parser.add_setting( - "--noabort", + "--abort", dest="abort_on_low_confidence", - action="store_false", - help="""Don't abort save operation when online match\nis of low confidence.\n\n""", + action=argparse.BooleanOptionalAction, + default=True, + help="""Abort save operation when online match\nis of low confidence.\n\n""", file=False, ) parser.add_setting( - "--nosummary", - dest="show_save_summary", - action="store_false", - help="Suppress the default summary after a save operation.\n\n", + "--summary", + default=True, + action=argparse.BooleanOptionalAction, + help="Show the summary after a save operation.\n\n", file=False, ) parser.add_setting( @@ -175,7 +176,8 @@ def register_options(parser: settngs.Manager) -> None: file=False, ) parser.add_setting( - "--terse", + "--quiet", + "-q", action="store_true", help="Don't say much (for print mode).", file=False, @@ -191,10 +193,11 @@ def register_options(parser: settngs.Manager) -> None: file=False, ) parser.add_setting( - "--no-overwrite", - dest="no_overwrite", - action="store_true", - help="""Don't modify tag block if it already exists (relevant for -s or -c).""", + "--overwrite", + dest="overwrite", + action=argparse.BooleanOptionalAction, + default=True, + help="""Apply metadata to already tagged archives (relevant for -s or -c).""", file=False, ) parser.add_setting("files", nargs="*", file=False) @@ -264,78 +267,78 @@ def register_commandline(parser: settngs.Manager) -> None: parser.add_group("runtime", register_options) -def validate_commandline_options(options: settngs.Values, parser: settngs.Manager) -> settngs.Values: +def validate_commandline_options(options: settngs.Config[settngs.Values], parser: settngs.Manager) -> settngs.Values: - if options["commands"]["version"]: + if options[0].commands_version: 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", ) - options["runtime"]["no_gui"] = any( + options[0].runtime_no_gui = any( [ - options["commands"]["print"], - options["commands"]["delete"], - options["commands"]["save"], - options["commands"]["copy"], - options["commands"]["rename"], - options["commands"]["export_to_zip"], - options["commands"]["only_set_cv_key"], + options[0].commands_print, + options[0].commands_delete, + options[0].commands_save, + options[0].commands_copy, + options[0].commands_rename, + options[0].commands_export_to_zip, + options[0].commands_only_set_cv_key, ] ) - if platform.system() == "Windows" and options["runtime"]["glob"]: + if platform.system() == "Windows" and options[0].runtime_glob: # no globbing on windows shell, so do it for them import glob - globs = options["runtime"]["files"] - options["runtime"]["files"] = [] + globs = options[0].runtime_files + options[0].runtime_files = [] for item in globs: - options["runtime"]["files"].extend(glob.glob(item)) + options[0].runtime_files.extend(glob.glob(item)) if ( - options["commands"]["only_set_cv_key"] - and options["comicvine"]["cv_api_key"] is None - and options["comicvine"]["cv_url"] is None + options[0].commands_only_set_cv_key + and options[0].comicvine_cv_api_key is None + and options[0].comicvine_cv_url is None ): parser.exit(message="Key not given!\n", status=1) - if not options["commands"]["only_set_cv_key"] and options["runtime"]["no_gui"] and not options["runtime"]["files"]: + if not options[0].commands_only_set_cv_key and options[0].runtime_no_gui and not options[0].runtime_files: parser.exit(message="Command requires at least one filename!\n", status=1) - if options["commands"]["delete"] and not options["runtime"]["type"]: + if options[0].commands_delete and not options[0].runtime_type: parser.exit(message="Please specify the type to delete with -t\n", status=1) - if options["commands"]["save"] and not options["runtime"]["type"]: + if options[0].commands_save and not options[0].runtime_type: parser.exit(message="Please specify the type to save with -t\n", status=1) - if options["commands"]["copy"]: - if not options["runtime"]["type"]: + if options[0].commands_copy: + if not options[0].runtime_type: parser.exit(message="Please specify the type to copy to with -t\n", status=1) - if len(options["commands"]["copy"]) > 1: + if len(options[0].commands_copy) > 1: parser.exit(message="Please specify only one type to copy to with -c\n", status=1) - options["commands"]["copy"] = options["commands"]["copy"][0] + options[0].commands_copy = options[0].commands_copy[0] - if options["runtime"]["recursive"]: - options["runtime"]["file_list"] = utils.get_recursive_filelist(options["runtime"]["files"]) + if options[0].runtime_recursive: + options[0].runtime_file_list = utils.get_recursive_filelist(options[0].runtime_files) else: - options["runtime"]["file_list"] = options["runtime"]["files"] + options[0].runtime_file_list = options[0].runtime_files # take a crack at finding rar exe, if not set already - if options["general"]["rar_exe_path"].strip() in ("", "rar"): + if options[0].general_rar_exe_path.strip() in ("", "rar"): if platform.system() == "Windows": # look in some likely places for Windows machines if os.path.exists(r"C:\Program Files\WinRAR\Rar.exe"): - options["general"]["rar_exe_path"] = r"C:\Program Files\WinRAR\Rar.exe" + options[0].general_rar_exe_path = r"C:\Program Files\WinRAR\Rar.exe" elif os.path.exists(r"C:\Program Files (x86)\WinRAR\Rar.exe"): - options["general"]["rar_exe_path"] = r"C:\Program Files (x86)\WinRAR\Rar.exe" + options[0].general_rar_exe_path = r"C:\Program Files (x86)\WinRAR\Rar.exe" else: if os.path.exists("/opt/homebrew/bin"): utils.add_to_path("/opt/homebrew/bin") # see if it's in the path of unix user rarpath = utils.which("rar") if rarpath is not None: - options["general"]["rar_exe_path"] = "rar" + options[0].general_rar_exe_path = "rar" return options diff --git a/comictaggerlib/ctoptions/file.py b/comictaggerlib/ctoptions/file.py index 1d50643..7791feb 100644 --- a/comictaggerlib/ctoptions/file.py +++ b/comictaggerlib/ctoptions/file.py @@ -26,17 +26,17 @@ def general(parser: settngs.Manager) -> None: def internal(parser: settngs.Manager) -> None: # automatic settings parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False) - parser.add_setting("last_selected_save_data_style", default=0, cmdline=False) - parser.add_setting("last_selected_load_data_style", default=0, cmdline=False) + parser.add_setting("save_data_style", default=0, cmdline=False) + parser.add_setting("load_data_style", default=0, cmdline=False) parser.add_setting("last_opened_folder", default="", cmdline=False) - parser.add_setting("last_main_window_width", default=0, cmdline=False) - parser.add_setting("last_main_window_height", default=0, cmdline=False) - parser.add_setting("last_main_window_x", default=0, cmdline=False) - parser.add_setting("last_main_window_y", default=0, cmdline=False) - parser.add_setting("last_form_side_width", default=-1, cmdline=False) - parser.add_setting("last_list_side_width", default=-1, cmdline=False) - parser.add_setting("last_filelist_sorted_column", default=-1, cmdline=False) - parser.add_setting("last_filelist_sorted_order", default=0, cmdline=False) + parser.add_setting("window_width", default=0, cmdline=False) + parser.add_setting("window_height", default=0, cmdline=False) + parser.add_setting("window_x", default=0, cmdline=False) + parser.add_setting("window_y", default=0, cmdline=False) + parser.add_setting("form_width", default=-1, cmdline=False) + parser.add_setting("list_width", default=-1, cmdline=False) + parser.add_setting("sort_column", default=-1, cmdline=False) + parser.add_setting("sort_direction", default=0, cmdline=False) def identifier(parser: settngs.Manager) -> None: @@ -155,8 +155,8 @@ def cbl(parser: settngs.Manager) -> None: parser.add_setting("--copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction) parser.add_setting("--copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction) parser.add_setting("--copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--apply-cbl-transform-on-cv-import", default=False, action=argparse.BooleanOptionalAction) - parser.add_setting("--apply-cbl-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--apply-transform-on-import", default=False, action=argparse.BooleanOptionalAction) + parser.add_setting("--apply-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction) def rename(parser: settngs.Manager) -> None: @@ -175,14 +175,16 @@ def rename(parser: settngs.Manager) -> None: help="Attempts to intelligently cleanup whitespace when renaming", ) parser.add_setting( - "--set-extension-based-on-archive", + "--auto-extension", + dest="set_extension_based_on_archive", default=True, action=argparse.BooleanOptionalAction, help="Automatically sets the extension based on the archive type e.g. cbr for rar, cbz for zip", ) parser.add_setting("--dir", default="", help="The directory to move renamed files to") parser.add_setting( - "--move-to-dir", + "--move", + dest="move_to_dir", default=False, action=argparse.BooleanOptionalAction, help="Enables moving renamed files to a separate directory", @@ -231,7 +233,7 @@ def autotag(parser: settngs.Manager) -> None: parser.add_setting("remove_archive_after_successful_match", default=False, cmdline=False) parser.add_setting( "-w", - "--wait-on-cv-rate-limit", + "--wait-on-rate-limit", dest="wait_and_retry_on_rate_limit", action=argparse.BooleanOptionalAction, default=True, @@ -239,13 +241,11 @@ def autotag(parser: settngs.Manager) -> None: ) -def validate_settings(options: dict[str, dict[str, Any]], parser: settngs.Manager) -> dict[str, dict[str, Any]]: - options["identifier"]["publisher_filter"] = [ - x.strip() for x in options["identifier"]["publisher_filter"] if x.strip() - ] - options["rename"]["replacements"] = Replacements( - [Replacement(x[0], x[1], x[2]) for x in options["rename"]["replacements"][0]], - [Replacement(x[0], x[1], x[2]) for x in options["rename"]["replacements"][1]], +def validate_settings(options: settngs.Config[settngs.Values], parser: settngs.Manager) -> dict[str, dict[str, Any]]: + options[0].identifier_publisher_filter = [x.strip() for x in options[0].identifier_publisher_filter if x.strip()] + options[0].rename_replacements = Replacements( + [Replacement(x[0], x[1], x[2]) for x in options[0].rename_replacements[0]], + [Replacement(x[0], x[1], x[2]) for x in options[0].rename_replacements[1]], ) return options diff --git a/comictaggerlib/fileselectionlist.py b/comictaggerlib/fileselectionlist.py index 5086d05..e5066a5 100644 --- a/comictaggerlib/fileselectionlist.py +++ b/comictaggerlib/fileselectionlist.py @@ -57,10 +57,7 @@ class FileSelectionList(QtWidgets.QWidget): dataColNum = fileColNum def __init__( - self, - parent: QtWidgets.QWidget, - options: settngs.ConfigValues, - dirty_flag_verification: Callable[[str, str], bool], + self, parent: QtWidgets.QWidget, options: settngs.Namespace, dirty_flag_verification: Callable[[str, str], bool] ) -> None: super().__init__(parent) @@ -227,7 +224,7 @@ class FileSelectionList(QtWidgets.QWidget): else: QtWidgets.QMessageBox.information(self, "File/Folder Open", "No readable comic archives were found.") - if rar_added and not utils.which(self.options["general"]["rar_exe_path"] or "rar"): + if rar_added and not utils.which(self.options.general_rar_exe_path or "rar"): self.rar_ro_message() self.twList.setSortingEnabled(True) @@ -281,7 +278,7 @@ class FileSelectionList(QtWidgets.QWidget): if self.is_list_dupe(path): return self.get_current_list_row(path) - ca = ComicArchive(path, self.options["general"]["rar_exe_path"], str(graphics_path / "nocover.png")) + ca = ComicArchive(path, self.options.general_rar_exe_path, str(graphics_path / "nocover.png")) if ca.seems_to_be_a_comic_archive(): row: int = self.twList.rowCount() diff --git a/comictaggerlib/gui.py b/comictaggerlib/gui.py index 3bad58c..f3ad995 100644 --- a/comictaggerlib/gui.py +++ b/comictaggerlib/gui.py @@ -82,10 +82,12 @@ except ImportError as e: qt_available = False -def open_tagger_window(talker_api: ComicTalker, options: settngs.Config, error: tuple[str, bool] | None) -> None: +def open_tagger_window( + talker_api: ComicTalker, options: settngs.Config[settngs.Namespace], error: tuple[str, bool] | None +) -> None: os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" args = [] - if options[0]["runtime"]["darkmode"]: + if options[0].runtime_darkmode: args.extend(["-platform", "windows:darkmode=2"]) args.extend(sys.argv) app = Application(args) @@ -95,7 +97,7 @@ def open_tagger_window(talker_api: ComicTalker, options: settngs.Config, error: raise SystemExit(1) # needed to catch initial open file events (macOS) - app.openFileRequest.connect(lambda x: options[0]["runtime"]["files"].append(x.toLocalFile())) + app.openFileRequest.connect(lambda x: options[0].runtime_files.append(x.toLocalFile())) if platform.system() == "Darwin": # Set the MacOS dock icon @@ -123,7 +125,7 @@ def open_tagger_window(talker_api: ComicTalker, options: settngs.Config, error: QtWidgets.QApplication.processEvents() try: - tagger_window = TaggerWindow(options[0]["runtime"]["files"], options, talker_api) + tagger_window = TaggerWindow(options[0].runtime_files, options, talker_api) tagger_window.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) tagger_window.show() diff --git a/comictaggerlib/issueidentifier.py b/comictaggerlib/issueidentifier.py index 368213f..a16e9d2 100644 --- a/comictaggerlib/issueidentifier.py +++ b/comictaggerlib/issueidentifier.py @@ -72,7 +72,7 @@ class IssueIdentifier: result_one_good_match = 4 result_multiple_good_matches = 5 - def __init__(self, comic_archive: ComicArchive, options: settngs.ConfigValues, talker_api: ComicTalker) -> None: + def __init__(self, comic_archive: ComicArchive, options: settngs.Namespace, talker_api: ComicTalker) -> None: self.options = options self.talker_api = talker_api self.comic_archive: ComicArchive = comic_archive @@ -96,10 +96,10 @@ class IssueIdentifier: # used to eliminate series names that are too long based on our search # string - self.series_match_thresh = options["identifier"]["series_match_identify_thresh"] + self.series_match_thresh = options.identifier_series_match_identify_thresh # used to eliminate unlikely publishers - self.publisher_filter = [s.strip().casefold() for s in options["identifier"]["publisher_filter"]] + self.publisher_filter = [s.strip().casefold() for s in options.identifier_publisher_filter] self.additional_metadata = GenericMetadata() self.output_function: Callable[[str], None] = IssueIdentifier.default_write_output @@ -201,10 +201,10 @@ class IssueIdentifier: # try to get some metadata from filename md_from_filename = ca.metadata_from_filename( - self.options["filename"]["complicated_parser"], - self.options["filename"]["remove_c2c"], - self.options["filename"]["remove_fcbd"], - self.options["filename"]["remove_publisher"], + self.options.filename_complicated_parser, + self.options.filename_remove_c2c, + self.options.filename_remove_fcbd, + self.options.filename_remove_publisher, ) working_md = md_from_filename.copy() @@ -255,7 +255,7 @@ class IssueIdentifier: return Score(score=0, url="", hash=0) try: - url_image_data = ImageFetcher(self.options["runtime"]["config"].user_cache_dir).fetch( + url_image_data = ImageFetcher(self.options.runtime_config.user_cache_dir).fetch( primary_thumb_url, blocking=True ) except ImageFetcherException as e: @@ -277,7 +277,7 @@ class IssueIdentifier: if use_remote_alternates: for alt_url in alt_urls: try: - alt_url_image_data = ImageFetcher(self.options["runtime"]["config"].user_cache_dir).fetch( + alt_url_image_data = ImageFetcher(self.options.runtime_config.user_cache_dir).fetch( alt_url, blocking=True ) except ImageFetcherException as e: diff --git a/comictaggerlib/issueselectionwindow.py b/comictaggerlib/issueselectionwindow.py index ca03b26..dc51dc3 100644 --- a/comictaggerlib/issueselectionwindow.py +++ b/comictaggerlib/issueselectionwindow.py @@ -44,7 +44,7 @@ class IssueSelectionWindow(QtWidgets.QDialog): def __init__( self, parent: QtWidgets.QWidget, - options: settngs.ConfigValues, + options: settngs.Namespace, talker_api: ComicTalker, series_id: int, issue_number: str, @@ -54,10 +54,7 @@ class IssueSelectionWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "issueselectionwindow.ui", self) self.coverWidget = CoverImageWidget( - self.coverImageContainer, - CoverImageWidget.AltCoverMode, - options["runtime"]["config"].user_cache_dir, - talker_api, + self.coverImageContainer, CoverImageWidget.AltCoverMode, options.runtime_config.user_cache_dir, talker_api ) gridlayout = QtWidgets.QGridLayout(self.coverImageContainer) gridlayout.addWidget(self.coverWidget) diff --git a/comictaggerlib/main.py b/comictaggerlib/main.py index 6153129..639ebb3 100644 --- a/comictaggerlib/main.py +++ b/comictaggerlib/main.py @@ -21,7 +21,6 @@ import logging.handlers import platform import signal import sys -from typing import Any import settngs @@ -50,8 +49,8 @@ logger = logging.getLogger("comictagger") logger.setLevel(logging.DEBUG) -def update_publishers(options: dict[str, dict[str, Any]]) -> None: - json_file = options["runtime"]["config"].user_config_dir / "publishers.json" +def update_publishers(options: settngs.Namespace) -> None: + json_file = options.runtime_config.user_config_dir / "publishers.json" if json_file.exists(): try: utils.update_publishers(json.loads(json_file.read_text("utf-8"))) @@ -91,23 +90,26 @@ class App: ctoptions.register_settings(self.manager) def parse_options(self, config_paths: ctoptions.ComicTaggerPaths) -> None: - config, self.config_load_success = self.manager.parse_config(config_paths.user_config_dir / "settings.json") - options, definitions = config - options = ctoptions.validate_commandline_options(options, self.manager) - options = ctoptions.validate_settings(options, self.manager) - self.options = settngs.Config(options, definitions) + self.options, self.config_load_success = self.manager.parse_config( + config_paths.user_config_dir / "settings.json" + ) + self.options = self.manager.get_namespace(self.options) + + self.options = ctoptions.validate_commandline_options(self.options, self.manager) + self.options = ctoptions.validate_settings(self.options, self.manager) + self.options = self.options def initialize_dirs(self) -> None: - self.options[0]["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) - self.options[0]["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) - self.options[0]["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) - self.options[0]["runtime"]["config"].user_state_dir.mkdir(parents=True, exist_ok=True) - self.options[0]["runtime"]["config"].user_log_dir.mkdir(parents=True, exist_ok=True) - logger.debug("user_data_dir: %s", self.options[0]["runtime"]["config"].user_data_dir) - logger.debug("user_config_dir: %s", self.options[0]["runtime"]["config"].user_config_dir) - logger.debug("user_cache_dir: %s", self.options[0]["runtime"]["config"].user_cache_dir) - logger.debug("user_state_dir: %s", self.options[0]["runtime"]["config"].user_state_dir) - logger.debug("user_log_dir: %s", self.options[0]["runtime"]["config"].user_log_dir) + self.options[0].runtime_config.user_data_dir.mkdir(parents=True, exist_ok=True) + self.options[0].runtime_config.user_config_dir.mkdir(parents=True, exist_ok=True) + self.options[0].runtime_config.user_cache_dir.mkdir(parents=True, exist_ok=True) + self.options[0].runtime_config.user_state_dir.mkdir(parents=True, exist_ok=True) + self.options[0].runtime_config.user_log_dir.mkdir(parents=True, exist_ok=True) + logger.debug("user_data_dir: %s", self.options[0].runtime_config.user_data_dir) + logger.debug("user_config_dir: %s", self.options[0].runtime_config.user_config_dir) + logger.debug("user_cache_dir: %s", self.options[0].runtime_config.user_cache_dir) + logger.debug("user_state_dir: %s", self.options[0].runtime_config.user_state_dir) + logger.debug("user_log_dir: %s", self.options[0].runtime_config.user_log_dir) def main(self) -> None: assert self.options is not None @@ -130,18 +132,18 @@ class App: utils.load_publishers() update_publishers(self.options[0]) - if not qt_available and not self.options[0]["runtime"]["no_gui"]: - self.options[0]["runtime"]["no_gui"] = True + if not qt_available and not self.options[0].runtime_no_gui: + self.options[0].runtime_no_gui = True logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.") # manage the CV API key # None comparison is used so that the empty string can unset the value - if self.options[0]["comicvine"]["cv_api_key"] is not None or self.options[0]["comicvine"]["cv_url"] is not None: - settings_path = self.options[0]["runtime"]["config"].user_config_dir / "settings.json" + if self.options[0].comicvine_cv_api_key is not None or self.options[0].comicvine_cv_url is not None: + settings_path = self.options[0].runtime_config.user_config_dir / "settings.json" if self.config_load_success: self.manager.save_file(self.options[0], settings_path) - if self.options[0]["commands"]["only_set_cv_key"]: + if self.options[0].commands_only_set_cv_key: if self.config_load_success: print("Key set") # noqa: T201 return @@ -149,13 +151,13 @@ class App: try: talker_api = ct_api.get_comic_talker("comicvine")( # type: ignore[call-arg] version=version, - cache_folder=self.options[0]["runtime"]["config"].user_cache_dir, - series_match_thresh=self.options[0]["comicvine"]["series_match_search_thresh"], - remove_html_tables=self.options[0]["comicvine"]["remove_html_tables"], - use_series_start_as_volume=self.options[0]["comicvine"]["use_series_start_as_volume"], - wait_on_ratelimit=self.options[0]["autotag"]["wait_and_retry_on_rate_limit"], - api_url=self.options[0]["comicvine"]["cv_url"], - api_key=self.options[0]["comicvine"]["cv_api_key"], + cache_folder=self.options[0].runtime_config.user_cache_dir, + series_match_thresh=self.options[0].comicvine_series_match_search_thresh, + remove_html_tables=self.options[0].comicvine_remove_html_tables, + use_series_start_as_volume=self.options[0].comicvine_use_series_start_as_volume, + wait_on_ratelimit=self.options[0].autotag_wait_and_retry_on_rate_limit, + api_url=self.options[0].comicvine_cv_url, + api_key=self.options[0].comicvine_cv_api_key, ) except TalkerError as e: logger.exception("Unable to load talker") @@ -163,10 +165,10 @@ class App: if not self.config_load_success: error = ( - f"Failed to load settings, check the log located in '{self.options[0]['runtime']['config'].user_log_dir}' for more details", + f"Failed to load settings, check the log located in '{self.options[0].runtime_config.user_log_dir}' for more details", True, ) - if self.options[0]["runtime"]["no_gui"]: + if self.options[0].runtime_no_gui: 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) diff --git a/comictaggerlib/matchselectionwindow.py b/comictaggerlib/matchselectionwindow.py index db93736..7736ab6 100644 --- a/comictaggerlib/matchselectionwindow.py +++ b/comictaggerlib/matchselectionwindow.py @@ -47,10 +47,7 @@ class MatchSelectionWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "matchselectionwindow.ui", self) self.altCoverWidget = CoverImageWidget( - self.altCoverContainer, - CoverImageWidget.AltCoverMode, - options["runtime"]["config"].user_cache_dir, - talker_api, + self.altCoverContainer, CoverImageWidget.AltCoverMode, options.runtime_config.user_cache_dir, talker_api ) gridlayout = QtWidgets.QGridLayout(self.altCoverContainer) gridlayout.addWidget(self.altCoverWidget) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index 5006769..6addfac 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -61,19 +61,19 @@ class RenameWindow(QtWidgets.QDialog): self.rename_list: list[str] = [] self.btnSettings.clicked.connect(self.modify_settings) - platform = "universal" if self.options[0]["filename"]["rename_strict"] else "auto" - self.renamer = FileRenamer(None, platform=platform, replacements=self.options[0]["rename"]["replacements"]) + platform = "universal" if self.options[0].filename_rename_strict else "auto" + self.renamer = FileRenamer(None, platform=platform, replacements=self.options[0].rename_replacements) self.do_preview() def config_renamer(self, ca: ComicArchive, md: GenericMetadata | None = None) -> str: - self.renamer.set_template(self.options[0]["filename"]["rename_template"]) - self.renamer.set_issue_zero_padding(self.options[0]["filename"]["rename_issue_number_padding"]) - self.renamer.set_smart_cleanup(self.options[0]["filename"]["rename_use_smart_string_cleanup"]) - self.renamer.replacements = self.options[0]["rename"]["replacements"] + self.renamer.set_template(self.options[0].filename_rename_template) + self.renamer.set_issue_zero_padding(self.options[0].filename_rename_issue_number_padding) + self.renamer.set_smart_cleanup(self.options[0].filename_rename_use_smart_string_cleanup) + self.renamer.replacements = self.options[0].rename_replacements new_ext = ca.path.suffix # default - if self.options[0]["filename"]["rename_set_extension_based_on_archive"]: + if self.options[0].filename_rename_set_extension_based_on_archive: if ca.is_sevenzip(): new_ext = ".cb7" elif ca.is_zip(): @@ -85,13 +85,13 @@ class RenameWindow(QtWidgets.QDialog): md = ca.read_metadata(self.data_style) if md.is_empty: md = ca.metadata_from_filename( - self.options[0]["filename"]["complicated_parser"], - self.options[0]["filename"]["remove_c2c"], - self.options[0]["filename"]["remove_fcbd"], - self.options[0]["filename"]["remove_publisher"], + self.options[0].filename_complicated_parser, + self.options[0].filename_remove_c2c, + self.options[0].filename_remove_fcbd, + self.options[0].filename_remove_publisher, ) self.renamer.set_metadata(md) - self.renamer.move = self.options[0]["filename"]["rename_move_to_dir"] + self.renamer.move = self.options[0].filename_rename_move_to_dir return new_ext def do_preview(self) -> None: @@ -104,7 +104,7 @@ class RenameWindow(QtWidgets.QDialog): try: new_name = self.renamer.determine_name(new_ext) except ValueError as e: - logger.exception("Invalid format string: %s", self.options[0]["filename"]["rename_template"]) + logger.exception("Invalid format string: %s", self.options[0].filename_rename_template) QtWidgets.QMessageBox.critical( self, "Invalid format string!", @@ -119,7 +119,7 @@ class RenameWindow(QtWidgets.QDialog): except Exception as e: logger.exception( "Formatter failure: %s metadata: %s", - self.options[0]["filename"]["rename_template"], + self.options[0].filename_rename_template, self.renamer.metadata, ) QtWidgets.QMessageBox.critical( @@ -197,9 +197,7 @@ class RenameWindow(QtWidgets.QDialog): folder = get_rename_dir( comic[0], - self.options[0]["filename"]["rename_dir"] - if self.options[0]["filename"]["rename_move_to_dir"] - else None, + self.options[0].filename_rename_dir if self.options[0].filename_rename_move_to_dir else None, ) full_path = folder / comic[1] diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 9e5fa3f..48ac846 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -269,54 +269,50 @@ class SettingsWindow(QtWidgets.QDialog): def settings_to_form(self) -> None: # Copy values from settings to form - self.leRarExePath.setText(self.options[0]["general"]["rar_exe_path"]) - self.sbNameMatchIdentifyThresh.setValue(self.options[0]["identifier"]["series_match_identify_thresh"]) - self.sbNameMatchSearchThresh.setValue(self.options[0]["comicvine"]["series_match_search_thresh"]) - self.tePublisherFilter.setPlainText("\n".join(self.options[0]["identifier"]["publisher_filter"])) + self.leRarExePath.setText(self.options[0].general_rar_exe_path) + self.sbNameMatchIdentifyThresh.setValue(self.options[0].identifier_series_match_identify_thresh) + self.sbNameMatchSearchThresh.setValue(self.options[0].comicvine_series_match_search_thresh) + self.tePublisherFilter.setPlainText("\n".join(self.options[0].identifier_publisher_filter)) - self.cbxCheckForNewVersion.setChecked(self.options[0]["general"]["check_for_new_version"]) + self.cbxCheckForNewVersion.setChecked(self.options[0].general_check_for_new_version) - self.cbxComplicatedParser.setChecked(self.options[0]["filename"]["complicated_parser"]) - self.cbxRemoveC2C.setChecked(self.options[0]["filename"]["remove_c2c"]) - self.cbxRemoveFCBD.setChecked(self.options[0]["filename"]["remove_fcbd"]) - self.cbxRemovePublisher.setChecked(self.options[0]["filename"]["remove_publisher"]) + self.cbxComplicatedParser.setChecked(self.options[0].filename_complicated_parser) + self.cbxRemoveC2C.setChecked(self.options[0].filename_remove_c2c) + self.cbxRemoveFCBD.setChecked(self.options[0].filename_remove_fcbd) + self.cbxRemovePublisher.setChecked(self.options[0].filename_remove_publisher) self.switch_parser() - self.cbxUseSeriesStartAsVolume.setChecked(self.options[0]["comicvine"]["use_series_start_as_volume"]) - self.cbxClearFormBeforePopulating.setChecked( - self.options[0]["comicvine"]["clear_form_before_populating_from_cv"] - ) - self.cbxRemoveHtmlTables.setChecked(self.options[0]["comicvine"]["remove_html_tables"]) + self.cbxUseSeriesStartAsVolume.setChecked(self.options[0].comicvine_use_series_start_as_volume) + self.cbxClearFormBeforePopulating.setChecked(self.options[0].comicvine_clear_form_before_populating_from_cv) + self.cbxRemoveHtmlTables.setChecked(self.options[0].comicvine_remove_html_tables) - self.cbxUseFilter.setChecked(self.options[0]["comicvine"]["always_use_publisher_filter"]) - self.cbxSortByYear.setChecked(self.options[0]["comicvine"]["sort_series_by_year"]) - self.cbxExactMatches.setChecked(self.options[0]["comicvine"]["exact_series_matches_first"]) + self.cbxUseFilter.setChecked(self.options[0].comicvine_always_use_publisher_filter) + self.cbxSortByYear.setChecked(self.options[0].comicvine_sort_series_by_year) + self.cbxExactMatches.setChecked(self.options[0].comicvine_exact_series_matches_first) - self.leKey.setText(self.options[0]["comicvine"]["cv_api_key"]) - self.leURL.setText(self.options[0]["comicvine"]["cv_url"]) + self.leKey.setText(self.options[0].comicvine_cv_api_key) + self.leURL.setText(self.options[0].comicvine_cv_url) - self.cbxAssumeLoneCreditIsPrimary.setChecked(self.options[0]["cbl"]["assume_lone_credit_is_primary"]) - self.cbxCopyCharactersToTags.setChecked(self.options[0]["cbl"]["copy_characters_to_tags"]) - self.cbxCopyTeamsToTags.setChecked(self.options[0]["cbl"]["copy_teams_to_tags"]) - self.cbxCopyLocationsToTags.setChecked(self.options[0]["cbl"]["copy_locations_to_tags"]) - self.cbxCopyStoryArcsToTags.setChecked(self.options[0]["cbl"]["copy_storyarcs_to_tags"]) - self.cbxCopyNotesToComments.setChecked(self.options[0]["cbl"]["copy_notes_to_comments"]) - self.cbxCopyWebLinkToComments.setChecked(self.options[0]["cbl"]["copy_weblink_to_comments"]) - self.cbxApplyCBLTransformOnCVIMport.setChecked(self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"]) - self.cbxApplyCBLTransformOnBatchOperation.setChecked( - self.options[0]["cbl"]["apply_cbl_transform_on_bulk_operation"] - ) + self.cbxAssumeLoneCreditIsPrimary.setChecked(self.options[0].cbl_assume_lone_credit_is_primary) + self.cbxCopyCharactersToTags.setChecked(self.options[0].cbl_copy_characters_to_tags) + self.cbxCopyTeamsToTags.setChecked(self.options[0].cbl_copy_teams_to_tags) + self.cbxCopyLocationsToTags.setChecked(self.options[0].cbl_copy_locations_to_tags) + self.cbxCopyStoryArcsToTags.setChecked(self.options[0].cbl_copy_storyarcs_to_tags) + self.cbxCopyNotesToComments.setChecked(self.options[0].cbl_copy_notes_to_comments) + self.cbxCopyWebLinkToComments.setChecked(self.options[0].cbl_copy_weblink_to_comments) + self.cbxApplyCBLTransformOnCVIMport.setChecked(self.options[0].cbl_apply_transform_on_import) + self.cbxApplyCBLTransformOnBatchOperation.setChecked(self.options[0].cbl_apply_transform_on_bulk_operation) - self.leRenameTemplate.setText(self.options[0]["rename"]["template"]) - self.leIssueNumPadding.setText(str(self.options[0]["rename"]["issue_number_padding"])) - self.cbxSmartCleanup.setChecked(self.options[0]["rename"]["use_smart_string_cleanup"]) - self.cbxChangeExtension.setChecked(self.options[0]["rename"]["set_extension_based_on_archive"]) - self.cbxMoveFiles.setChecked(self.options[0]["rename"]["move_to_dir"]) - self.leDirectory.setText(self.options[0]["rename"]["dir"]) - self.cbxRenameStrict.setChecked(self.options[0]["rename"]["strict"]) + self.leRenameTemplate.setText(self.options[0].rename_template) + self.leIssueNumPadding.setText(str(self.options[0].rename_issue_number_padding)) + self.cbxSmartCleanup.setChecked(self.options[0].rename_use_smart_string_cleanup) + self.cbxChangeExtension.setChecked(self.options[0].rename_set_extension_based_on_archive) + self.cbxMoveFiles.setChecked(self.options[0].rename_move_to_dir) + self.leDirectory.setText(self.options[0].rename_dir) + self.cbxRenameStrict.setChecked(self.options[0].rename_strict) for table, replacments in zip( - (self.twLiteralReplacements, self.twValueReplacements), self.options[0]["rename"]["replacements"] + (self.twLiteralReplacements, self.twValueReplacements), self.options[0].rename_replacements ): table.clearContents() for i in reversed(range(table.rowCount())): @@ -351,7 +347,7 @@ class SettingsWindow(QtWidgets.QDialog): self.rename_test() if self.rename_error is not None: if isinstance(self.rename_error, ValueError): - logger.exception("Invalid format string: %s", self.options[0]["rename"]["template"]) + logger.exception("Invalid format string: %s", self.options[0].rename_template) QtWidgets.QMessageBox.critical( self, "Invalid format string!", @@ -365,7 +361,7 @@ class SettingsWindow(QtWidgets.QDialog): return else: logger.exception( - "Formatter failure: %s metadata: %s", self.options[0]["rename"]["template"], self.renamer.metadata + "Formatter failure: %s metadata: %s", self.options[0].rename_template, self.renamer.metadata ) QtWidgets.QMessageBox.critical( self, @@ -378,71 +374,65 @@ class SettingsWindow(QtWidgets.QDialog): ) # Copy values from form to settings and save - self.options[0]["general"]["rar_exe_path"] = str(self.leRarExePath.text()) + self.options[0].general_rar_exe_path = str(self.leRarExePath.text()) # make sure rar program is now in the path for the rar class - if self.options[0]["general"]["rar_exe_path"]: - utils.add_to_path(os.path.dirname(self.options[0]["general"]["rar_exe_path"])) + if self.options[0].general_rar_exe_path: + utils.add_to_path(os.path.dirname(self.options[0].general_rar_exe_path)) if not str(self.leIssueNumPadding.text()).isdigit(): self.leIssueNumPadding.setText("0") - self.options[0]["general"]["check_for_new_version"] = self.cbxCheckForNewVersion.isChecked() + self.options[0].general_check_for_new_version = self.cbxCheckForNewVersion.isChecked() - self.options[0]["identifier"]["series_match_identify_thresh"] = self.sbNameMatchIdentifyThresh.value() - self.options[0]["comicvine"]["series_match_search_thresh"] = self.sbNameMatchSearchThresh.value() - self.options[0]["identifier"]["publisher_filter"] = [ + self.options[0].identifier_series_match_identify_thresh = self.sbNameMatchIdentifyThresh.value() + self.options[0].comicvine_series_match_search_thresh = self.sbNameMatchSearchThresh.value() + self.options[0].identifier_publisher_filter = [ x.strip() for x in str(self.tePublisherFilter.toPlainText()).splitlines() if x.strip() ] - self.options[0]["filename"]["complicated_parser"] = self.cbxComplicatedParser.isChecked() - self.options[0]["filename"]["remove_c2c"] = self.cbxRemoveC2C.isChecked() - self.options[0]["filename"]["remove_fcbd"] = self.cbxRemoveFCBD.isChecked() - self.options[0]["filename"]["remove_publisher"] = self.cbxRemovePublisher.isChecked() + self.options[0].filename_complicated_parser = self.cbxComplicatedParser.isChecked() + self.options[0].filename_remove_c2c = self.cbxRemoveC2C.isChecked() + self.options[0].filename_remove_fcbd = self.cbxRemoveFCBD.isChecked() + self.options[0].filename_remove_publisher = self.cbxRemovePublisher.isChecked() - self.options[0]["comicvine"]["use_series_start_as_volume"] = self.cbxUseSeriesStartAsVolume.isChecked() - self.options[0]["comicvine"][ - "clear_form_before_populating_from_cv" - ] = self.cbxClearFormBeforePopulating.isChecked() - self.options[0]["comicvine"]["remove_html_tables"] = self.cbxRemoveHtmlTables.isChecked() + self.options[0].comicvine_use_series_start_as_volume = self.cbxUseSeriesStartAsVolume.isChecked() + self.options[0].comicvine_clear_form_before_populating_from_cv = self.cbxClearFormBeforePopulating.isChecked() + self.options[0].comicvine_remove_html_tables = self.cbxRemoveHtmlTables.isChecked() - self.options[0]["comicvine"]["always_use_publisher_filter"] = self.cbxUseFilter.isChecked() - self.options[0]["comicvine"]["sort_series_by_year"] = self.cbxSortByYear.isChecked() - self.options[0]["comicvine"]["exact_series_matches_first"] = self.cbxExactMatches.isChecked() + self.options[0].comicvine_always_use_publisher_filter = self.cbxUseFilter.isChecked() + self.options[0].comicvine_sort_series_by_year = self.cbxSortByYear.isChecked() + self.options[0].comicvine_exact_series_matches_first = self.cbxExactMatches.isChecked() if self.leKey.text().strip(): - self.options[0]["comicvine"]["cv_api_key"] = self.leKey.text().strip() - self.talker_api.api_key = self.options[0]["comicvine"]["cv_api_key"] + self.options[0].comicvine_cv_api_key = self.leKey.text().strip() + self.talker_api.api_key = self.options[0].comicvine_cv_api_key if self.leURL.text().strip(): - self.options[0]["comicvine"]["cv_url"] = self.leURL.text().strip() - self.talker_api.api_url = self.options[0]["comicvine"]["cv_url"] + self.options[0].comicvine_cv_url = self.leURL.text().strip() + self.talker_api.api_url = self.options[0].comicvine_cv_url - self.options[0]["cbl"]["assume_lone_credit_is_primary"] = self.cbxAssumeLoneCreditIsPrimary.isChecked() - self.options[0]["cbl"]["copy_characters_to_tags"] = self.cbxCopyCharactersToTags.isChecked() - self.options[0]["cbl"]["copy_teams_to_tags"] = self.cbxCopyTeamsToTags.isChecked() - self.options[0]["cbl"]["copy_locations_to_tags"] = self.cbxCopyLocationsToTags.isChecked() - self.options[0]["cbl"]["copy_storyarcs_to_tags"] = self.cbxCopyStoryArcsToTags.isChecked() - self.options[0]["cbl"]["copy_notes_to_comments"] = self.cbxCopyNotesToComments.isChecked() - self.options[0]["cbl"]["copy_weblink_to_comments"] = self.cbxCopyWebLinkToComments.isChecked() - self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"] = self.cbxApplyCBLTransformOnCVIMport.isChecked() - self.options[0]["cbl"][ - "apply_cbl_transform_on_bulk_operation" - ] = self.cbxApplyCBLTransformOnBatchOperation.isChecked() + self.options[0].cbl_assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked() + self.options[0].cbl_copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked() + self.options[0].cbl_copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked() + self.options[0].cbl_copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked() + self.options[0].cbl_copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked() + self.options[0].cbl_copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked() + self.options[0].cbl_copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked() + self.options[0].cbl_apply_transform_on_import = self.cbxApplyCBLTransformOnCVIMport.isChecked() + self.options[0].cbl_apply_transform_on_bulk_operation = self.cbxApplyCBLTransformOnBatchOperation.isChecked() - self.options[0]["rename"]["template"] = str(self.leRenameTemplate.text()) - self.options[0]["rename"]["issue_number_padding"] = int(self.leIssueNumPadding.text()) - self.options[0]["rename"]["use_smart_string_cleanup"] = self.cbxSmartCleanup.isChecked() - self.options[0]["rename"]["set_extension_based_on_archive"] = self.cbxChangeExtension.isChecked() - self.options[0]["rename"]["move_to_dir"] = self.cbxMoveFiles.isChecked() - self.options[0]["rename"]["dir"] = self.leDirectory.text() + self.options[0].rename_template = str(self.leRenameTemplate.text()) + self.options[0].rename_issue_number_padding = int(self.leIssueNumPadding.text()) + self.options[0].rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked() + self.options[0].rename_set_extension_based_on_archive = self.cbxChangeExtension.isChecked() + self.options[0].rename_move_to_dir = self.cbxMoveFiles.isChecked() + self.options[0].rename_dir = self.leDirectory.text() - self.options[0]["rename"]["strict"] = self.cbxRenameStrict.isChecked() - self.options[0]["rename"]["replacements"] = self.get_replacemnts() + self.options[0].rename_strict = self.cbxRenameStrict.isChecked() + self.options[0].rename_replacements = self.get_replacemnts() - settngs.save_file( - self.options[0], self.options[1], self.options[0]["runtime"]["config"].user_config_dir / "settings.json" - ) + settngs.save_file(self.options, self.options[0].runtime_config.user_config_dir / "settings.json") self.parent().options = self.options QtWidgets.QDialog.accept(self) @@ -450,8 +440,8 @@ class SettingsWindow(QtWidgets.QDialog): self.select_file(self.leRarExePath, "RAR") def clear_cache(self) -> None: - ImageFetcher(self.options[0]["runtime"]["config"].cache_folder).clear_cache() - ComicCacher(self.options[0]["runtime"]["config"].cache_folder, version).clear_cache() + ImageFetcher(self.options[0].runtime_config.cache_folder).clear_cache() + ComicCacher(self.options[0].runtime_config.cache_folder, version).clear_cache() QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.") def test_api_key(self) -> None: diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py index e8c7c61..b9aa040 100644 --- a/comictaggerlib/taggerwindow.py +++ b/comictaggerlib/taggerwindow.py @@ -87,18 +87,16 @@ class TaggerWindow(QtWidgets.QMainWindow): uic.loadUi(ui_path / "taggerwindow.ui", self) self.options = options - if not options: - self.options = ({}, {}) self.talker_api = talker_api self.log_window = self.setup_logger() # prevent multiple instances socket = QtNetwork.QLocalSocket(self) - socket.connectToServer(options[0]["internal"]["install_id"]) + socket.connectToServer(options[0].internal_install_id) alive = socket.waitForConnected(3000) if alive: logger.setLevel(logging.INFO) - logger.info("Another application with key [%s] is already running", options[0]["internal"]["install_id"]) + logger.info("Another application with key [%s] is already running", options[0].internal_install_id) # send file list to other instance if file_list: socket.write(pickle.dumps(file_list)) @@ -110,15 +108,15 @@ class TaggerWindow(QtWidgets.QMainWindow): # listen on a socket to prevent multiple instances self.socketServer = QtNetwork.QLocalServer(self) self.socketServer.newConnection.connect(self.on_incoming_socket_connection) - ok = self.socketServer.listen(options[0]["internal"]["install_id"]) + ok = self.socketServer.listen(options[0].internal_install_id) if not ok: if self.socketServer.serverError() == QtNetwork.QAbstractSocket.SocketError.AddressInUseError: - self.socketServer.removeServer(options[0]["internal"]["install_id"]) - ok = self.socketServer.listen(options[0]["internal"]["install_id"]) + self.socketServer.removeServer(options[0].internal_install_id) + ok = self.socketServer.listen(options[0].internal_install_id) if not ok: logger.error( "Cannot start local socket with key [%s]. Reason: %s", - options[0]["internal"]["install_id"], + options[0].internal_install_id, self.socketServer.errorString(), ) sys.exit() @@ -139,8 +137,8 @@ class TaggerWindow(QtWidgets.QMainWindow): self.fileSelectionList.selectionChanged.connect(self.file_list_selection_changed) self.fileSelectionList.listCleared.connect(self.file_list_cleared) self.fileSelectionList.set_sorting( - self.options[0]["internal"]["last_filelist_sorted_column"], - QtCore.Qt.SortOrder(self.options[0]["internal"]["last_filelist_sorted_order"]), + self.options[0].internal_sort_column, + QtCore.Qt.SortOrder(self.options[0].internal_sort_direction), ) # we can't specify relative font sizes in the UI designer, so @@ -158,13 +156,13 @@ class TaggerWindow(QtWidgets.QMainWindow): self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png"))) - if options[0]["runtime"]["type"] and isinstance(options[0]["runtime"]["type"][0], int): + if options[0].runtime_type and isinstance(options[0].runtime_type[0], int): # respect the command line option tag type - options[0]["internal"]["last_selected_save_data_style"] = options[0]["runtime"]["type"][0] - options[0]["internal"]["last_selected_load_data_style"] = options[0]["runtime"]["type"][0] + options[0].internal_save_data_style = options[0].runtime_type[0] + options[0].internal_load_data_style = options[0].runtime_type[0] - self.save_data_style = options[0]["internal"]["last_selected_save_data_style"] - self.load_data_style = options[0]["internal"]["last_selected_load_data_style"] + self.save_data_style = options[0].internal_save_data_style + self.load_data_style = options[0].internal_load_data_style self.setAcceptDrops(True) self.config_menus() @@ -229,13 +227,8 @@ class TaggerWindow(QtWidgets.QMainWindow): self.show() self.set_app_position() - if self.options[0]["internal"]["last_form_side_width"] != -1: - self.splitter.setSizes( - [ - self.options[0]["internal"]["last_form_side_width"], - self.options[0]["internal"]["last_list_side_width"], - ] - ) + if self.options[0].internal_form_width != -1: + self.splitter.setSizes([self.options[0].internal_form_width, self.options[0].internal_list_width]) self.raise_() QtCore.QCoreApplication.processEvents() self.resizeEvent(None) @@ -252,7 +245,7 @@ class TaggerWindow(QtWidgets.QMainWindow): if len(file_list) != 0: self.fileSelectionList.add_path_list(file_list) - if self.options[0]["dialog"]["show_disclaimer"]: + if self.options[0].dialog_show_disclaimer: checked = OptionalMessageDialog.msg( self, "Welcome!", @@ -267,9 +260,9 @@ use ComicTagger on local copies of your comics.

Have fun! """, ) - self.options[0]["dialog"]["show_disclaimer"] = not checked + self.options[0].dialog_show_disclaimer = not checked - if self.options[0]["general"]["check_for_new_version"]: + if self.options[0].general_check_for_new_version: self.check_latest_version_online() def open_file_event(self, url: QtCore.QUrl) -> None: @@ -282,7 +275,7 @@ Have fun! def setup_logger(self) -> ApplicationLogWindow: try: - current_logs = (self.options[0]["runtime"]["config"].user_log_dir / "ComicTagger.log").read_text("utf-8") + current_logs = (self.options[0].runtime_config.user_log_dir / "ComicTagger.log").read_text("utf-8") except Exception: current_logs = "" root_logger = logging.getLogger() @@ -615,10 +608,10 @@ Have fun! def actual_load_current_archive(self) -> None: if self.metadata.is_empty and self.comic_archive is not None: self.metadata = self.comic_archive.metadata_from_filename( - self.options[0]["filename"]["complicated_parser"], - self.options[0]["filename"]["remove_c2c"], - self.options[0]["filename"]["remove_fcbd"], - self.options[0]["filename"]["remove_publisher"], + self.options[0].filename_complicated_parser, + self.options[0].filename_remove_c2c, + self.options[0].filename_remove_fcbd, + self.options[0].filename_remove_publisher, ) if len(self.metadata.pages) == 0 and self.comic_archive is not None: self.metadata.set_default_page_list(self.comic_archive.get_number_of_pages()) @@ -986,10 +979,10 @@ Have fun! # copy the form onto metadata object self.form_to_metadata() new_metadata = self.comic_archive.metadata_from_filename( - self.options[0]["filename"]["complicated_parser"], - self.options[0]["filename"]["remove_c2c"], - self.options[0]["filename"]["remove_fcbd"], - self.options[0]["filename"]["remove_publisher"], + self.options[0].filename_complicated_parser, + self.options[0].filename_remove_c2c, + self.options[0].filename_remove_fcbd, + self.options[0].filename_remove_publisher, split_words, ) if new_metadata is not None: @@ -1010,8 +1003,8 @@ Have fun! else: dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFiles) - if self.options[0]["internal"]["last_opened_folder"] is not None: - dialog.setDirectory(self.options[0]["internal"]["last_opened_folder"]) + if self.options[0].internal_last_opened_folder is not None: + dialog.setDirectory(self.options[0].internal_last_opened_folder) if not folder_mode: archive_filter = "Comic archive files (*.cbz *.zip *.cbr *.rar *.cb7 *.7z)" @@ -1093,10 +1086,10 @@ Have fun! else: QtWidgets.QApplication.restoreOverrideCursor() if new_metadata is not None: - if self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"]: + if self.options[0].cbl_apply_transform_on_import: new_metadata = CBLTransformer(new_metadata, self.options[0]).apply() - if self.options[0]["comicvine"]["clear_form_before_populating_from_cv"]: + if self.options[0].comicvine_clear_form_before_populating_from_cv: self.clear_form() notes = ( @@ -1151,7 +1144,7 @@ Have fun! "Change Tag Read Style", "If you change read tag style now, data in the form will be lost. Are you sure?" ): self.load_data_style = self.cbLoadDataStyle.itemData(s) - self.options[0]["internal"]["last_selected_load_data_style"] = self.load_data_style + self.options[0].internal_load_data_style = self.load_data_style self.update_menus() if self.comic_archive is not None: self.load_archive(self.comic_archive) @@ -1162,7 +1155,7 @@ Have fun! def set_save_data_style(self, s: int) -> None: self.save_data_style = self.cbSaveDataStyle.itemData(s) - self.options[0]["internal"]["last_selected_save_data_style"] = self.save_data_style + self.options[0].internal_save_data_style = self.save_data_style self.update_style_tweaks() self.update_menus() @@ -1387,14 +1380,9 @@ Have fun! settingswin.result() def set_app_position(self) -> None: - if self.options[0]["internal"]["last_main_window_width"] != 0: - self.move( - self.options[0]["internal"]["last_main_window_x"], self.options[0]["internal"]["last_main_window_y"] - ) - self.resize( - self.options[0]["internal"]["last_main_window_width"], - self.options[0]["internal"]["last_main_window_height"], - ) + if self.options[0].internal_window_width != 0: + self.move(self.options[0].internal_window_x, self.options[0].internal_window_y) + self.resize(self.options[0].internal_window_width, self.options[0].internal_window_height) else: screen = QtGui.QGuiApplication.primaryScreen().geometry() size = self.frameGeometry() @@ -1661,10 +1649,7 @@ Have fun! if ca.has_metadata(src_style) and ca.is_writable(): md = ca.read_metadata(src_style) - if ( - dest_style == MetaDataStyle.CBI - and self.options[0]["cbl"]["apply_cbl_transform_on_bulk_operation"] - ): + if dest_style == MetaDataStyle.CBI and self.options[0].cbl_apply_transform_on_bulk_operation: md = CBLTransformer(md, self.options[0]).apply() if not ca.write_metadata(md, dest_style): @@ -1703,7 +1688,7 @@ Have fun! logger.exception("Save aborted.") if not ct_md.is_empty: - if self.options[0]["cbl"]["apply_cbl_transform_on_cv_import"]: + if self.options[0].cbl_apply_transform_on_import: ct_md = CBLTransformer(ct_md, self.options[0]).apply() QtWidgets.QApplication.restoreOverrideCursor() @@ -1733,10 +1718,10 @@ Have fun! logger.error("Failed to load metadata for %s: %s", ca.path, e) if md.is_empty: md = ca.metadata_from_filename( - self.options[0]["filename"]["complicated_parser"], - self.options[0]["filename"]["remove_c2c"], - self.options[0]["filename"]["remove_fcbd"], - self.options[0]["filename"]["remove_publisher"], + self.options[0].filename_complicated_parser, + self.options[0].filename_remove_c2c, + self.options[0].filename_remove_fcbd, + self.options[0].filename_remove_publisher, dlg.split_words, ) if dlg.ignore_leading_digits_in_filename and md.series is not None: @@ -1822,7 +1807,7 @@ Have fun! ) md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger"))) - if self.options[0]["comicvine"]["auto_imprint"]: + if self.options[0].comicvine_auto_imprint: md.fix_publisher() if not ca.write_metadata(md, self.save_data_style): @@ -2000,19 +1985,17 @@ Have fun! f"Exit {self.appName}", "If you quit now, data in the form will be lost. Are you sure?" ): appsize = self.size() - self.options[0]["internal"]["last_main_window_width"] = appsize.width() - self.options[0]["internal"]["last_main_window_height"] = appsize.height() - self.options[0]["internal"]["last_main_window_x"] = self.x() - self.options[0]["internal"]["last_main_window_y"] = self.y() - self.options[0]["internal"]["last_form_side_width"] = self.splitter.sizes()[0] - self.options[0]["internal"]["last_list_side_width"] = self.splitter.sizes()[1] + self.options[0].internal_window_width = appsize.width() + self.options[0].internal_window_height = appsize.height() + self.options[0].internal_window_x = self.x() + self.options[0].internal_window_y = self.y() + self.options[0].internal_form_width = self.splitter.sizes()[0] + self.options[0].internal_list_width = self.splitter.sizes()[1] ( - self.options[0]["internal"]["last_filelist_sorted_column"], - self.options[0]["internal"]["last_filelist_sorted_order"], + self.options[0].internal_sort_column, + self.options[0].internal_sort_direction, ) = self.fileSelectionList.get_sorting() - settngs.save_file( - self.options[0], self.options[1], self.options[0]["runtime"]["config"].user_config_dir / "settings.json" - ) + settngs.save_file(self.options, self.options[0].runtime_config.user_config_dir / "settings.json") event.accept() else: @@ -2106,7 +2089,7 @@ Have fun! QtCore.QTimer.singleShot(1, self.fileSelectionList.revert_selection) return - self.options[0]["internal"]["last_opened_folder"] = os.path.abspath(os.path.split(comic_archive.path)[0]) + self.options[0].internal_last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0]) self.comic_archive = comic_archive try: self.metadata = self.comic_archive.read_metadata(self.load_data_style) @@ -2139,12 +2122,12 @@ Have fun! version_checker = VersionChecker() self.version_check_complete( version_checker.get_latest_version( - self.options[0]["internal"]["install_id"], self.options[0]["general"]["send_usage_stats"] + self.options[0].internal_install_id, self.options[0].general_send_usage_stats ) ) def version_check_complete(self, new_version: tuple[str, str]) -> None: - if new_version[0] not in (self.version, self.options[0]["dialog"]["dont_notify_about_this_version"]): + if new_version[0] not in (self.version, self.options[0].dialog_dont_notify_about_this_version): website = "https://github.com/comictagger/comictagger" checked = OptionalMessageDialog.msg( self, @@ -2155,7 +2138,7 @@ Have fun! "Don't tell me about this version again", ) if checked: - self.options[0]["dialog"]["dont_notify_about_this_version"] = new_version[0] + self.options[0].dialog_dont_notify_about_this_version = new_version[0] def on_incoming_socket_connection(self) -> None: # Accept connection from other instance. diff --git a/comictaggerlib/volumeselectionwindow.py b/comictaggerlib/volumeselectionwindow.py index 87c901f..abadae0 100644 --- a/comictaggerlib/volumeselectionwindow.py +++ b/comictaggerlib/volumeselectionwindow.py @@ -111,7 +111,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): issue_count: int, cover_index_list: list[int], comic_archive: ComicArchive | None, - options: settngs.ConfigValues, + options: settngs.Namespace, talker_api: ComicTalker, autoselect: bool = False, literal: bool = False, @@ -121,7 +121,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): uic.loadUi(ui_path / "volumeselectionwindow.ui", self) self.imageWidget = CoverImageWidget( - self.imageContainer, CoverImageWidget.URLMode, options["runtime"]["config"].user_cache_dir, talker_api + self.imageContainer, CoverImageWidget.URLMode, options.runtime_config.user_cache_dir, talker_api ) gridlayout = QtWidgets.QGridLayout(self.imageContainer) gridlayout.addWidget(self.imageWidget) @@ -156,7 +156,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): self.progdialog: QtWidgets.QProgressDialog | None = None self.search_thread: SearchThread | None = None - self.use_filter = self.options["comicvine"]["always_use_publisher_filter"] + self.use_filter = self.options.comicvine_always_use_publisher_filter # Load to retrieve settings self.talker_api = talker_api @@ -326,11 +326,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): def perform_query(self, refresh: bool = False) -> None: self.search_thread = SearchThread( - self.talker_api, - self.series_name, - refresh, - self.literal, - self.options["comicvine"]["series_match_search_thresh"], + self.talker_api, self.series_name, refresh, self.literal, self.options.comicvine_series_match_search_thresh ) self.search_thread.searchComplete.connect(self.search_complete) self.search_thread.progressUpdate.connect(self.search_progress_update) @@ -382,7 +378,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): # filter the publishers if enabled set if self.use_filter: try: - publisher_filter = {s.strip().casefold() for s in self.options["identifier"]["publisher_filter"]} + publisher_filter = {s.strip().casefold() for s in self.options.identifier_publisher_filter} # use '' as publisher name if None self.ct_search_results = list( filter( @@ -398,7 +394,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): # compare as str in case extra chars ie. '1976?' # - missing (none) values being converted to 'None' - consistent with prior behaviour in v1.2.3 # sort by start_year if set - if self.options["comicvine"]["sort_series_by_year"]: + if self.options.comicvine_sort_series_by_year: try: self.ct_search_results = sorted( self.ct_search_results, @@ -416,7 +412,7 @@ class VolumeSelectionWindow(QtWidgets.QDialog): logger.exception("bad data error sorting results by count_of_issues") # move sanitized matches to the front - if self.options["comicvine"]["exact_series_matches_first"]: + if self.options.comicvine_exact_series_matches_first: try: sanitized = utils.sanitize_title(self.series_name, False).casefold() sanitized_no_articles = utils.sanitize_title(self.series_name, True).casefold() diff --git a/requirements.txt b/requirements.txt index f36c9c8..2e5cfc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ pycountry pyicu; sys_platform == 'linux' or sys_platform == 'darwin' rapidfuzz>=2.12.0 requests==2.* -settngs==0.2.0 +settngs==0.3.0 text2digits typing_extensions wordninja diff --git a/tests/comiccacher_test.py b/tests/comiccacher_test.py index a9f87c2..2bc9791 100644 --- a/tests/comiccacher_test.py +++ b/tests/comiccacher_test.py @@ -7,8 +7,9 @@ from testing.comicdata import search_results def test_create_cache(options, mock_version): - comictalker.comiccacher.ComicCacher(options["runtime"]["config"].user_cache_dir, mock_version[0]) - assert (options["runtime"]["config"].user_cache_dir).exists() + settings, definitions = options + comictalker.comiccacher.ComicCacher(settings.runtime_config.user_cache_dir, mock_version[0]) + assert (settings.runtime_config.user_cache_dir).exists() def test_search_results(comic_cache): diff --git a/tests/conftest.py b/tests/conftest.py index b5dc79b..7b20081 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,7 @@ def comicvine_api( cv = comictalker.talkers.comicvine.ComicVineTalker( version=mock_version[0], - cache_folder=options["runtime"]["config"].user_cache_dir, + cache_folder=options[0].runtime_config.user_cache_dir, api_url="", api_key="", series_match_thresh=90, @@ -167,13 +167,13 @@ def options(settings_manager, tmp_path): comictaggerlib.ctoptions.register_commandline(settings_manager) comictaggerlib.ctoptions.register_settings(settings_manager) - defaults = settings_manager.defaults() - defaults["runtime"]["config"] = comictaggerlib.ctoptions.ComicTaggerPaths(tmp_path / "config") - defaults["runtime"]["config"].user_data_dir.mkdir(parents=True, exist_ok=True) - defaults["runtime"]["config"].user_config_dir.mkdir(parents=True, exist_ok=True) - defaults["runtime"]["config"].user_cache_dir.mkdir(parents=True, exist_ok=True) - defaults["runtime"]["config"].user_state_dir.mkdir(parents=True, exist_ok=True) - defaults["runtime"]["config"].user_log_dir.mkdir(parents=True, exist_ok=True) + defaults = settings_manager.get_namespace(settings_manager.defaults()) + defaults[0].runtime_config = comictaggerlib.ctoptions.ComicTaggerPaths(tmp_path / "config") + defaults[0].runtime_config.user_data_dir.mkdir(parents=True, exist_ok=True) + defaults[0].runtime_config.user_config_dir.mkdir(parents=True, exist_ok=True) + defaults[0].runtime_config.user_cache_dir.mkdir(parents=True, exist_ok=True) + defaults[0].runtime_config.user_state_dir.mkdir(parents=True, exist_ok=True) + defaults[0].runtime_config.user_log_dir.mkdir(parents=True, exist_ok=True) yield defaults @@ -185,4 +185,4 @@ def settings_manager(): @pytest.fixture def comic_cache(options, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]: - yield comictalker.comiccacher.ComicCacher(options["runtime"]["config"].user_cache_dir, mock_version[0]) + yield comictalker.comiccacher.ComicCacher(options[0].runtime_config.user_cache_dir, mock_version[0]) diff --git a/tests/issueidentifier_test.py b/tests/issueidentifier_test.py index bf970cb..3ff6561 100644 --- a/tests/issueidentifier_test.py +++ b/tests/issueidentifier_test.py @@ -10,7 +10,8 @@ import testing.comicvine def test_crop(cbz_double_cover, options, tmp_path, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, options, comicvine_api) + settings, definitions = options + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, settings, comicvine_api) cropped = ii.crop_cover(cbz_double_cover.archiver.read_file("double_cover.jpg")) original_cover = cbz_double_cover.get_page(0) @@ -22,14 +23,16 @@ def test_crop(cbz_double_cover, options, tmp_path, comicvine_api): @pytest.mark.parametrize("additional_md, expected", testing.comicdata.metadata_keys) def test_get_search_keys(cbz, options, additional_md, expected, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, options, comicvine_api) + settings, definitions = options + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, settings, comicvine_api) ii.set_additional_metadata(additional_md) assert expected == ii.get_search_keys() def test_get_issue_cover_match_score(cbz, options, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, options, comicvine_api) + settings, definitions = options + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, settings, comicvine_api) score = ii.get_issue_cover_match_score( int( comicapi.issuestring.IssueString( @@ -50,7 +53,8 @@ def test_get_issue_cover_match_score(cbz, options, comicvine_api): def test_search(cbz, options, comicvine_api): - ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, options, comicvine_api) + settings, definitions = options + ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, settings, comicvine_api) results = ii.search() cv_expected = { "series": f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})",