diff --git a/plugins/modformats/ZipMod.py b/plugins/modformats/ZipMod.py index c4c0a16..2a0c6df 100644 --- a/plugins/modformats/ZipMod.py +++ b/plugins/modformats/ZipMod.py @@ -1,3 +1,4 @@ +import json import os import zipfile @@ -9,7 +10,7 @@ def __init__(self, path): super().__init__(path) with zipfile.ZipFile(path, 'r') as F: with F.open("METADATA.json", 'r') as fJ: - self.init_metadata(fJ) + self.init_metadata(json.load(fJ)) try: with F.open("DESCRIPTION.html", 'r', encoding="utf8") as fJ: self.init_description(fJ) diff --git a/src/CoreOperations/ConfigManager.py b/src/CoreOperations/ConfigManager.py index 24ac0fa..f1ca266 100644 --- a/src/CoreOperations/ConfigManager.py +++ b/src/CoreOperations/ConfigManager.py @@ -1,6 +1,8 @@ import json import os +from src.Utils.JSONHandler import JSONHandler + class ConfigManager: __slots__ = ("init", @@ -13,7 +15,6 @@ class ConfigManager: "paths", "ui") - def __init__(self, ui): self.init = False self.ui = ui @@ -24,8 +25,7 @@ def __init__(self, ui): self.__block_pref = 0 self.__first_time_launch = False self.paths = None - - + def get_style_pref(self): return self.__style_pref @@ -89,8 +89,7 @@ def load_config(self): self.__first_time_launch = False def read_config(self): - with open(os.path.join(self.paths.config_loc, "config.json"), 'r') as F: - config_data = json.load(F) + with JSONHandler(os.path.join(self.paths.config_loc, "config.json"), "Error reading 'config.json'") as config_data: self.__game_loc = config_data.get("game_loc") self.__lang_pref = config_data.get("language") self.__style_pref = config_data.get("style") diff --git a/src/CoreOperations/Cymis/CymisParser.py b/src/CoreOperations/Cymis/CymisParser.py index 86876fe..1b58053 100644 --- a/src/CoreOperations/Cymis/CymisParser.py +++ b/src/CoreOperations/Cymis/CymisParser.py @@ -1,12 +1,14 @@ -import json import os import shutil from PyQt5 import QtCore + from src.Utils.Path import splitpath +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate + ##################### # DEFINE FLAG TYPES # ##################### @@ -19,6 +21,7 @@ def __init__(self, options): def get_flag_status(self): return {self.name: self.value} + class Flag: def __init__(self, options): self.name = options['Name'] @@ -29,6 +32,7 @@ def __init__(self, options): def get_flag_status(self): return {self.name: self.value} + class ChooseOne: def __init__(self, options): self.type = options['Type'] @@ -47,6 +51,7 @@ def get_flag_status(self): wizard_flags = {class_.__name__: class_ for class_ in [HiddenFlag, Flag, ChooseOne]} + ############################ # DEFINE BOOLEAN OPERATORS # ############################ @@ -61,6 +66,7 @@ def and_operator(arguments, operator_list, flag_list): parsed_flags.append(flag_list[item]) return all(parsed_flags) + def or_operator(arguments, operator_list, flag_list): parsed_flags = [] for item in arguments: @@ -72,6 +78,7 @@ def or_operator(arguments, operator_list, flag_list): parsed_flags.append(flag_list[item]) return any(parsed_flags) + def not_operator(arguments, operator_list, flag_list): to_flip = None @@ -84,10 +91,12 @@ def not_operator(arguments, operator_list, flag_list): return not(to_flip) -boolean_operators = {'and': and_operator, + +boolean_operators = {'and': and_operator, 'or': or_operator, 'not': not_operator} + ############################# # DEFINE INSTALLATION RULES # ############################# @@ -105,8 +114,10 @@ def copy_rule(path_prefix, rule, source, destination): else: assert 0, translate("ModWizards::CYMIS::Logging", "{filepath} is neither a file nor a directory.").format(filepath=source) + installation_rules = {'copy': copy_rule} + ################ # SAFETY UTILS # ################ @@ -114,9 +125,11 @@ def validate_path(path): path_directories = splitpath(path) assert not(any([check_if_only_periods(item) for item in path_directories])), translate("ModWizards::CYMIS::Logging", "Paths may not contain relative references.") + def check_if_only_periods(string): return all(char == '.' for char in string) and len(string) > 1 + ########################## # DEFINE CYMIS INSTALLER # ########################## @@ -127,13 +140,13 @@ def __init__(self): self.wizard_pages = [] self.installation_steps = [] self.version = None - self.enable_debug=None + self.enable_debug = None self.log = None @classmethod def init_from_script(cls, filepath, log): - with open(filepath, 'r') as F: - cymis = json.load(F) + with JSONHandler(filepath, f"Error Reading '{filepath}'") as stream: + cymis = stream instance = cls() instance.version = cymis['Version'] @@ -160,6 +173,7 @@ def install_mod(self): if installer_step.check_should_execute(): installer_step.execute_step() + class CymisInstallerPage: def __init__(self, page, log=None): self.title = page.get("Title", translate("ModWizards::CYMIS::FallbackUI", "No Title")) @@ -179,6 +193,7 @@ def retrieve_flags(self): retval.update(flag_status) return retval + class CymisInstallationStep: def __init__(self, path_prefix, step_info, flag_table, log): execution_condition = step_info.get("if") @@ -199,6 +214,7 @@ def check(): elif type(execution_condition) == dict: assert len(execution_condition) == 1, f"More than one boolean operator in dictionary: {execution_condition}." operator_name, operator_arguments = list(execution_condition.items())[0] + def check(): result = boolean_operators[operator_name](operator_arguments, boolean_operators, flag_table) if log is not None: @@ -217,4 +233,4 @@ def execute_step(self): self.get_rule(instruction["rule"])(self.path_prefix, **instruction) def get_rule(self, rule): - return installation_rules[rule] \ No newline at end of file + return installation_rules[rule] diff --git a/src/CoreOperations/ModBuildGraph/__init__.py b/src/CoreOperations/ModBuildGraph/__init__.py index 4126735..0fddc03 100644 --- a/src/CoreOperations/ModBuildGraph/__init__.py +++ b/src/CoreOperations/ModBuildGraph/__init__.py @@ -1,5 +1,3 @@ -import array -import json import os import sys @@ -11,10 +9,12 @@ from src.CoreOperations.PluginLoaders.ArchivesPluginLoader import get_archivetype_plugins_dict from src.CoreOperations.PluginLoaders.FilePacksPluginLoader import get_filepack_plugins_dict, get_filetype_to_filepack_plugins_map from src.Utils.Path import calc_has_dir_changed_info +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate + class BuildStep: __slots__ = ('mod', 'src', 'rule', 'rule_args', 'softcodes') @@ -25,6 +25,7 @@ def __init__(self, mod, src, rule, softcodes, *rule_args): self.rule_args = rule_args self.softcodes = softcodes + def make_interned_buildstep(dct): if 'mod' in dct: softcodes = dct.get('softcodes', {}) @@ -39,9 +40,11 @@ def make_interned_buildstep(dct): else: return dct + def get_interned_mod_index(path): - with open(os.path.join(path, "INDEX.json"), 'r') as F: - return json.load(F, object_hook=make_interned_buildstep) + with JSONHandler(os.path.join(path, "INDEX.json"), "Error reading 'INDEX.json'", object_hook=make_interned_buildstep) as stream: + return stream + def trim_dead_nodes(build_pipeline, rules): debug_n_thrown = 0 @@ -83,6 +86,7 @@ def trim_dead_nodes(build_pipeline, rules): return list(steps) + def categorise_build_targets(build_graphs, ops, log, updateLog): filetypes = get_targettable_filetypes() filepacks = get_filepack_plugins_dict() diff --git a/src/CoreOperations/ModInstallation/PipelineRunners.py b/src/CoreOperations/ModInstallation/PipelineRunners.py index 2264778..703d2ee 100644 --- a/src/CoreOperations/ModInstallation/PipelineRunners.py +++ b/src/CoreOperations/ModInstallation/PipelineRunners.py @@ -7,11 +7,13 @@ from src.CoreOperations.PluginLoaders.FilePacksPluginLoader import get_filepack_plugins_dict from src.CoreOperations.PluginLoaders.PatchersPluginLoader import get_patcher_plugins_dict from src.Utils.Signals import StandardRunnableSignals +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate patchers = get_patcher_plugins_dict() + class PipelineRunner(QtCore.QRunnable): __slots__ = ("softcodes", "target", "filepack", "path_prefix", "paths", "cache_index", "archive_postaction", "signals") @@ -111,6 +113,7 @@ def execute(self): except Exception as e: self.handleException(e) + class ArchivePipelineCollection(QtCore.QObject): __slots__ = ("archive", "ops", "ui", "softcodes", "threadpool", "pre_message", "cache_index") @@ -171,11 +174,11 @@ def make_link(actor): self.raise_exception.emit(e) def finalise_build(self): - with open(self.ops.paths.patch_cache_index_loc, 'r') as F: - total_cache = json.load(F) + with JSONHandler(self.ops.paths.patch_cache_index_loc, f"Error reading '{self.ops.paths.patch_cache_index_loc}'") as stream: + total_cache = stream for file, hashval in self.cache_index.items(): total_cache[os.path.join(self.archive.get_prefix(), file)] = hashval with open(self.ops.paths.patch_cache_index_loc, 'w') as F: json.dump(total_cache, F, indent=2) - self.finished.emit() \ No newline at end of file + self.finished.emit() diff --git a/src/CoreOperations/ModInstallation/__init__.py b/src/CoreOperations/ModInstallation/__init__.py index 2579a35..fb8e61e 100644 --- a/src/CoreOperations/ModInstallation/__init__.py +++ b/src/CoreOperations/ModInstallation/__init__.py @@ -1,5 +1,5 @@ -import json import os +import sys import shutil from PyQt5 import QtCore @@ -9,18 +9,21 @@ from src.CoreOperations.ModInstallation.PipelineRunners import ArchivePipelineCollection from src.CoreOperations.ModInstallation.VariableParser import parse_mod_variables, scan_variables_for_softcodes from src.CoreOperations.PluginLoaders.FilePacksPluginLoader import get_filepack_plugins_dict +from src.Utils.JSONHandler import JSONHandler from src.Utils.MBE import mbetable_to_dict, dict_to_mbetable from libs.dscstools import DSCSTools translate = QtCore.QCoreApplication.translate + def generate_step_message(cur_items, cur_total): return translate("ModInstall", "[Step {ratio}]").format(ratio=f"{cur_items}/{cur_total}") - + + def generate_prefixed_message(cur_items, cur_total, msg): return f">> {generate_step_message(cur_items, cur_total)} {msg}" - -import sys + + def format_exception(exception): return type(exception)(f"Error on line {sys.exc_info()[-1].tb_lineno} in file {__file__}:" + f" {exception}") @@ -149,8 +152,8 @@ def process_graph(self, build_graphs, softcode_lookup): # Create the cache folder if it doesn't exist os.makedirs(self.ops.paths.patch_cache_loc, exist_ok=True) - with open(self.ops.paths.patch_cache_index_loc, 'r') as F: - cache_index = json.load(F) + with JSONHandler(self.ops.paths.patch_cache_index_loc, f"Error reading '{self.ops.paths.patch_cache_index_loc}") as stream: + cache_index = stream # Now prepare the process the build graph # Init some variables to count the number of packs in the build graph, @@ -214,6 +217,7 @@ def sendLog(self, msg): def sendUpdateLog(self, msg): self.updateLog.emit(generate_prefixed_message(self.substep, self.nsteps, msg)) + class ResourceBootstrapper(QtCore.QObject): log = QtCore.pyqtSignal(str) updateLog = QtCore.pyqtSignal(str) @@ -246,8 +250,8 @@ def execute(self): try: self.log.emit(translate("ModInstall", "{curr_step_msg} Checking required resources...").format(curr_step_msg=self.pre_message)) - with open(os.path.join(self.ops.paths.config_loc, "filelist.json")) as F: - resource_archives = json.load(F) + with JSONHandler(os.path.join(self.ops.paths.config_loc, "filelist.json"), "Error reading 'filelist.json'") as stream: + resource_archives = stream required_resources = {} for archive_type, archives in self.build_graphs.items(): @@ -368,6 +372,7 @@ def make_link(actor): except Exception as e: self.raise_exception.emit(e) + # 1) Skip sort if data isn't in the build graph # 2) Enumerate the sorts # 3) Make it work for all MDB1 archives @@ -623,6 +628,7 @@ def sortmode_compress_keygen_digimarket(self, item_id, build_item_para, build_it name = self.name_getter(item_id[0], build_item_name, 1)[1] return (int(item_sort_id), name.encode('utf8')) + class ArchiveBuilder(QtCore.QObject): finished = QtCore.pyqtSignal() clean_up = QtCore.pyqtSignal() @@ -777,5 +783,3 @@ def install(self): self.thread.start() except Exception as e: self.raise_exception.emit(e) - - diff --git a/src/CoreOperations/ModRegistry/BuildScript.py b/src/CoreOperations/ModRegistry/BuildScript.py index 40c32c2..cea2f2d 100644 --- a/src/CoreOperations/ModRegistry/BuildScript.py +++ b/src/CoreOperations/ModRegistry/BuildScript.py @@ -1,15 +1,16 @@ import copy import itertools -import json import os import re from PyQt5 import QtCore -from src.Utils.Path import splitpath, check_path_is_safe +from src.Utils.Path import check_path_is_safe +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate + class RegexVariable: __slots__ = ("pattern",) @@ -30,6 +31,7 @@ def getValues(self, modfiles_dir): if match: yield match[0] + class RangeVariable: __slots__ = ("start", "stop", "step") @@ -46,15 +48,16 @@ def getValues(self, modfiles_dir): for i in range(self.start, self.stop, self.step): yield (i,) - variable_types = {"Range": RangeVariable, "Regex": RegexVariable} + def check_path(path): if os.path.pardir in os.path.realpath(path): raise ValueError(translate("ModRegistry::BuildScript", "Parent directory token {0} is not allowed in paths: \'{1}\'").format(os.path.pardir, path)) + class BuildScriptStep: __slots__ = ("src_file", "rules", "rule_args") @@ -79,6 +82,7 @@ def __init__(self, target, src_file, rules=None, rule_args=None): raise ValueError(translate("ModRegistry::BuildScript", "\'Rules\' for Mod File \'{0}\' of Target File \'{1}\' is not a string, list, or dict: {2}").format(src_file, target, type(rules))) self.rule_args = rule_args + class BuildScriptPipeline: __slots__ = ("original_target_key", "buildsteps") @@ -86,6 +90,7 @@ def __init__(self, original_target_key, buildsteps): self.original_target_key = original_target_key self.buildsteps = buildsteps + class BuildScript: def __init__(self): self.target_dict = {} @@ -122,8 +127,8 @@ def extract_build_steps(key, definition): @classmethod def from_json(cls, json_file, modfiles_dir): - with open(json_file, 'r') as F: - data = json.load(F) + with JSONHandler(json_file, f"Error reading '{json_file}'") as stream: + data = stream instance = cls() if type(data) != dict: diff --git a/src/CoreOperations/ModRegistry/CoreModFormats.py b/src/CoreOperations/ModRegistry/CoreModFormats.py index 9e067cc..751827b 100644 --- a/src/CoreOperations/ModRegistry/CoreModFormats.py +++ b/src/CoreOperations/ModRegistry/CoreModFormats.py @@ -1,7 +1,8 @@ import os -import json import shutil +from src.Utils.JSONHandler import JSONHandler + class ModFile: def __init__(self, path): @@ -13,8 +14,7 @@ def __init__(self, path): self.category = "-" self.description = "" - def init_metadata(self, iostream): - data = json.load(iostream) + def init_metadata(self, data): self.name = data.get('Name', self.filename) self.author = data.get('Author', "-") self.version = data.get('Version', "-") @@ -38,13 +38,14 @@ def toLoose(self, path): def get_filelist(self): raise NotImplementedError + class LooseMod(ModFile): def __init__(self, path): super().__init__(path) metadata_path = os.path.join(path, 'METADATA.json') - if os.path.exists(metadata_path): - with open(metadata_path, 'r', encoding="utf-8") as F: - self.init_metadata(F) + with JSONHandler(metadata_path, "Error reading JSON file 'METADATA.json'") as stream: + self.init_metadata(stream) + desc_path = os.path.join(path, 'DESCRIPTION.html') if os.path.exists(desc_path): with open(desc_path, 'r', encoding="utf8") as fJ: diff --git a/src/CoreOperations/ModRegistry/Indexing.py b/src/CoreOperations/ModRegistry/Indexing.py index d1143e2..a6dff32 100644 --- a/src/CoreOperations/ModRegistry/Indexing.py +++ b/src/CoreOperations/ModRegistry/Indexing.py @@ -1,16 +1,18 @@ import hashlib -import json import os import sys from PyQt5 import QtCore + from src.CoreOperations.ModRegistry.Softcoding import search_string_for_softcodes, search_bytestring_for_softcodes from src.Utils.Path import splitpath from src.CoreOperations.PluginLoaders.FiletypesPluginLoader import get_build_element_plugins_dict from src.CoreOperations.ModRegistry.BuildScript import BuildScript +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate + class IndexFileException(Exception): def __init__(self, base_msg, modfile_name): super().__init__(self) @@ -19,10 +21,12 @@ def __init__(self, base_msg, modfile_name): def __str__(self): return f"{self.modfile_name}: {self.base_msg}" - + + def make_buildgraph_path(filepath): return os.path.join(*splitpath(filepath)[4:]) + def index_mod_contents(modpath, filetypes): last_edit_time = 0 paths_hash = hashlib.sha256() @@ -56,19 +60,22 @@ def index_mod_contents(modpath, filetypes): return retval, last_edit_time, paths_hash.hexdigest() + def register_softcode(softcode_list, all_softcodes, aliased_match, aliases, offset): match = find_softcode_alias(aliased_match, aliases) if match not in softcode_list: softcode_list[match] = [] softcode_list[match].append([offset, len(aliased_match) + 2]) # + 2 For [ and ] all_softcodes.add(match) - + + def find_softcode_alias(match, aliases): for alias, identity in aliases.items(): if match[:len(alias)] == alias: return identity + match[len(alias):] return match - + + def index_mod_softcodes(modpath, filetypes, mod_contents_index, aliases): softcodable_filetypes = sorted(list(set([be.get_identifier() for filetype in filetypes for be in filetype.get_build_elements() if getattr(be, "enable_softcodes", False)]))) softcodes = {} @@ -94,7 +101,8 @@ def index_mod_softcodes(modpath, filetypes, mod_contents_index, aliases): line = F.readline() softcodes[file] = file_softcodes return softcodes, all_softcodes - + + def get_targets_softcodes(filetargets, aliases): target_softcodes = {} all_softcodes = set() @@ -113,10 +121,11 @@ def get_targets_softcodes(filetargets, aliases): del target_softcodes[target] return target_softcodes, all_softcodes + def include_autorequests(config_path, contents, archive_lookup): request_build_element = get_build_element_plugins_dict()[("request", "request")] - with open(os.path.join(config_path, "filelist.json"), 'r') as F: - filelist = json.load(F) + with JSONHandler(os.path.join(config_path, "filelist.json"), f"Error reading 'filelist.json'") as stream: + filelist = stream out = {} for filetype in contents: @@ -151,13 +160,14 @@ def alias_decoder(obj): return obj else: assert 0, "ALIASES.json must be a dict of strings." - + + def build_index(config_path, filepath, filetypes, archive_getter, archive_from_path_getter, targets_getter, rules_getter, filepath_getter): alias_path = os.path.join(os.path.split(filepath)[0], "ALIASES.json") if os.path.isfile(alias_path): try: - with open(alias_path, 'r') as F: - aliases = json.load(F, object_hook=alias_decoder) + with JSONHandler(alias_path, "Error reading 'ALIASES.json'", object_hook=alias_decoder) as stream: + aliases = stream except Exception as e: raise Exception(translate("Indexing", "Could not read ALIASES.json, error was \"{error_msg}\".").format(error_msg=e.__str__())) else: @@ -168,7 +178,6 @@ def build_index(config_path, filepath, filetypes, archive_getter, archive_from_p try: buildscript = BuildScript.from_json(buildscript_path, filepath) except Exception as e: - raise e raise Exception(translate("Indexing", "Could not read BUILD.json, error was \"{error_msg}\".").format(error_msg=e.__str__())) else: buildscript = None diff --git a/src/CoreOperations/ModRegistry/ModFormatVersions.py b/src/CoreOperations/ModRegistry/ModFormatVersions.py index 74d71a0..93548da 100644 --- a/src/CoreOperations/ModRegistry/ModFormatVersions.py +++ b/src/CoreOperations/ModRegistry/ModFormatVersions.py @@ -1,12 +1,13 @@ -import json import os from PyQt5 import QtCore from src.Utils.Path import splitpath, check_path_is_safe +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate + class ModFormatVersion1: version = 1 @@ -19,8 +20,8 @@ def get_archives(modpath, contents): for filetype in contents: for filepath in contents[filetype]: out[filepath] = ("MDB1", "DSDBP") - with open(os.path.split(modpath)[0] + os.sep + "METADATA.json", 'r') as F: - metadata = json.load(F) + with JSONHandler(os.path.split(modpath)[0] + os.sep + "METADATA.json", "Error reading 'METADATA.json'") as stream: + metadata = stream if "Archives" in metadata: for file, archive in metadata["Archives"].items(): assert type(archive) == str, translate("ModMetadataParsing", "'Archive' for file {file} was not a string: {archive}.").format(file=file, archive=archive) @@ -36,8 +37,8 @@ def get_archive_from_path(path, a, b): def get_targets(modpath, contents, archives): modpath_rel = os.path.join(*splitpath(modpath)[-3:]) out = {} - with open(os.path.split(modpath)[0] + os.sep + "METADATA.json", 'r') as F: - metadata = json.load(F) + with JSONHandler(os.path.split(modpath)[0] + os.sep + "METADATA.json", "Error reading 'METADATA.json'") as stream: + metadata = stream already_handled_files = set() if "Targets" in metadata: for file, targets in metadata["Targets"].items(): @@ -79,8 +80,8 @@ def get_targets(modpath, contents, archives): @staticmethod def get_rules(modpath, contents): out = {} - with open(os.path.split(modpath)[0] + os.sep + "METADATA.json", 'r') as F: - metadata = json.load(F) + with JSONHandler(os.path.split(modpath)[0] + os.sep + "METADATA.json", "Error reading 'METADATA.json'") as stream: + metadata = stream if "Rules" in metadata: for file, rule in metadata["Rules"].items(): assert type(rule) == str, translate("ModMetadataParsing", "'Rule' for file {file} was not a string.").format(file=file) @@ -90,7 +91,8 @@ def get_rules(modpath, contents): @staticmethod def get_filepath(filepath, archive): return filepath - + + class ModFormatVersion2(ModFormatVersion1): version = 2 default_mdb1s = {'DSDB', 'DSDBA', 'DSDBS', 'DSDBSP', 'DSDBP', @@ -100,9 +102,8 @@ class ModFormatVersion2(ModFormatVersion1): @staticmethod def get_archives( modpath, contents): out = {} - - with open(os.path.split(modpath)[0] + os.sep + "METADATA.json", 'r') as F: - metadata = json.load(F) + with JSONHandler(os.path.split(modpath)[0] + os.sep + "METADATA.json", "Error reading 'METADATA.json'") as stream: + metadata = stream mod_MDB1s = set(metadata.get("MDB1", [])) mod_AFS2s = set(metadata.get("AFS2", [])) @@ -153,6 +154,7 @@ def get_filepath(filepath, archive): # assert type(targets) == list, f"'Targets' for file {file} was not a list." # out[file].extend(targets) # return out - + + mod_format_versions = {1: ModFormatVersion1, 2: ModFormatVersion2} diff --git a/src/CoreOperations/ModRegistry/__init__.py b/src/CoreOperations/ModRegistry/__init__.py index 17c9fda..88041da 100644 --- a/src/CoreOperations/ModRegistry/__init__.py +++ b/src/CoreOperations/ModRegistry/__init__.py @@ -11,13 +11,16 @@ from src.CoreOperations.PluginLoaders.ModInstallersPluginLoader import get_modinstallers_plugins from src.Utils.Exceptions import UnrecognisedModFormatError, ModInstallWizardCancelled,\ InstallerWizardParsingError, SpecificInstallerWizardParsingError +from src.Utils.JSONHandler import JSONHandler + translate = QtCore.QCoreApplication.translate + def get_mod_version(path): metadata_path = os.path.join(path, 'METADATA.json') - with open(metadata_path, 'r', encoding="utf-8") as F: - metadata = json.load(F) + with JSONHandler(metadata_path, "Error reading 'METADATA.json'") as stream: + metadata = stream version = metadata.get("FormatVersion", 1) highest_version = max(mod_format_versions.keys()) @@ -36,6 +39,7 @@ def check_mod_type(path): return modformat(path) return False + def check_installer_type(path, messageLog): """ Figures out which of the supported mod formats the input file/folder is in, if any. @@ -51,14 +55,14 @@ def check_installer_type(path, messageLog): raise InstallerWizardParsingError(e.__str__()) from e return False + class ModRegistry: def __init__(self, ui, paths, profile_manager, raise_exception): self.ui = ui self.paths = paths self.profile_manager = profile_manager self.raise_exception = raise_exception - - + def index_mod(self, modpath): mod_format_version = mod_format_versions[get_mod_version(modpath)] index = build_index(self.paths.config_loc, @@ -111,7 +115,6 @@ def register_mod(self, path): shutil.rmtree(os.path.join(self.paths.mods_loc, mod_name)) self.ui.log(translate("ModRegistry", "The following error occured when trying to register {mod_name}: {error}").format(mod_name=mod_name, error=e)) - def unregister_mod(self, index): mod_name = os.path.split(self.profile_manager.mods[index].path)[1] try: @@ -175,7 +178,6 @@ def reregister_mod(self, index): except Exception as e: self.ui.log(translate("ModRegistry", "The following error occured when trying to re-register {mod_name}: {error}").format(mod_name=mod_name, error=e)) - def detect_mods(self, ignore_debugs=True): """Check for qualifying mods in the registered mods folder.""" dirpath = self.paths.mods_loc diff --git a/src/CoreOperations/PluginLoaders/PluginLoad.py b/src/CoreOperations/PluginLoaders/PluginLoad.py index 6be9a4d..285a977 100644 --- a/src/CoreOperations/PluginLoaders/PluginLoad.py +++ b/src/CoreOperations/PluginLoaders/PluginLoad.py @@ -1,10 +1,10 @@ import importlib import inspect -import json import os import sys from src.Utils.Path import splitpath +from src.Utils.JSONHandler import JSONHandler def load_sorted_plugins_in(directory, predicate): @@ -13,6 +13,7 @@ def load_sorted_plugins_in(directory, predicate): return sort_plugins(filetype_plugins, plugin_order) + def load_plugins_in(directory, predicate): results = [] for file in os.listdir(directory): @@ -26,6 +27,7 @@ def load_plugins_in(directory, predicate): results.extend([getattr(module, class_) for class_ in classes_in_module]) return results + def load_plugins_from(directory, file, predicate): file, ext = os.path.splitext(file) if ext != '.py': @@ -40,12 +42,12 @@ def load_plugins_from(directory, file, predicate): def get_plugin_sort_order(directory): priority_file = os.path.join(directory, "_priorities.json") if os.path.exists(priority_file): - with open(priority_file, 'r') as F: - order = json.load(F) - return order + with JSONHandler(priority_file, "Error reading '_priorities.json'") as order: + return order else: return [] + def sort_plugins(members, ordering): member_names = [member.__name__ for member in members] diff --git a/src/CoreOperations/ProfileManager.py b/src/CoreOperations/ProfileManager.py index 5a49fbb..9ce4512 100644 --- a/src/CoreOperations/ProfileManager.py +++ b/src/CoreOperations/ProfileManager.py @@ -4,6 +4,7 @@ from PyQt5 import QtCore, QtWidgets from src.UI.CustomWidgets import OnlyOneProfileNotification +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate @@ -21,8 +22,8 @@ def __init__(self, ui, paths): mod_categories_file = os.path.join(paths.config_loc, "modcategories.json") try: - with open(mod_categories_file, 'r', encoding="utf-8") as F: - self.mod_categories = set(json.load(F)) + with JSONHandler(mod_categories_file, "Error loading 'modcategories.json'") as stream: + self.mod_categories = set(stream) except: self.ui.log(translate("ProfileManager", "Failed to load mod categories from {filepath}").format(filepath=mod_categories_file)) self.mod_categories = set() @@ -77,19 +78,18 @@ def apply_profile(self): return filepath = self.profile_path(current_text) if os.path.exists(filepath): - with open(filepath, 'r', encoding="utf-8") as F: - try: - current_profile = json.load(F) - except Exception as e: - self.ui.log - ( - translate - ( - "ProfileManager", - "The following error occured when loading the profile {profile_name}: {error}" - ).format(profile_name=current_text, error=e) - ) - current_profile = {} + try: + with JSONHandler(filepath, f"Error reading '{current_text}'") as stream: + current_profile = stream + except Exception as e: + self.ui.log + ( + translate( + "ProfileManager", + "The following error occured when loading the profile {profile_name}: {error}" + ).format(profile_name=current_text, error=e) + ) + current_profile = {} actives = {} for path, value in current_profile.items(): if path in self.modpath_to_id: @@ -111,7 +111,6 @@ def save_profile(self): with open(filepath, 'w', encoding="utf-8") as F: json.dump(self.profile_from_active_mods(), F, indent=4) - def delete_profile(self): if self.profile_selector.count() == 1: notification = OnlyOneProfileNotification() @@ -126,8 +125,7 @@ def delete_profile(self): def get_active_mods(self): activation_states = self.mods_display.get_mod_activation_states() return [self.mods[int(idx)] for idx, state in activation_states.items() if state == 2] - - + def profile_from_active_mods(self): activation_states = self.mods_display.get_mod_activation_states() return {self.mods[int(idx)].path: state for idx, state in activation_states.items()} diff --git a/src/CoreOperations/SoftcodeManager.py b/src/CoreOperations/SoftcodeManager.py index 253ead9..68b90fc 100644 --- a/src/CoreOperations/SoftcodeManager.py +++ b/src/CoreOperations/SoftcodeManager.py @@ -5,8 +5,11 @@ from PyQt5 import QtCore +from src.Utils.JSONHandler import JSONHandler + translate = QtCore.QCoreApplication.translate + class SoftcodeCategoryDefinition: __slots__ = ("name", "min", "max", "span", "src", "value_lambda", "methods", "subcategory_defs") @@ -50,6 +53,7 @@ def call_formatting_func(self, func_def_dict, value, parent_value): raise Exception(f"Unknown formatting argument \'{value}\'.") return func_def_dict["return"].format(*arglist) + class SoftcodeCategory: __slots__ = ("key_gaps", "definition", "keys") @@ -99,7 +103,8 @@ def get(self, key_name): def get_data_as_serialisable(self): return {key_name: key.get_data_as_serialisable() for key_name, key in self.keys.items()} - + + class SoftcodeKey: chunk_delimiter = "|" kv_delimiter = "::" @@ -141,7 +146,8 @@ def get_data_as_serialisable(self): if len(self.subcategories): res.append({cat_name: cat.get_data_as_serialisable() for cat_name, cat in self.subcategories.items()}) return res - + + class SoftcodeManager(SoftcodeKey): __slots__ = ("category_defs", "paths") @@ -168,8 +174,10 @@ def add_subcategory(self, subcategory, data): def load_subcategory_from_json(self, main_filename): try: - with open(os.path.join(self.paths.softcodes_loc, main_filename), 'r', encoding="utf8") as F: - dct = json.load(F) + with JSONHandler(os.path.join(self.paths.softcodes_loc, f"Error reading '{main_filename}'")) as stream: + dct = stream + except json.decoder.JSONDecodeError as e: + print('error', e) except Exception as e: raise Exception(f"Attempted to read Softcode definition \'{main_filename}\', encountered error: {e}") from e category_name = os.path.splitext(main_filename)[0] @@ -177,12 +185,13 @@ def load_subcategory_from_json(self, main_filename): cache_loc = os.path.join(self.paths.softcode_cache_loc, main_filename) - if os.path.exists(cache_loc): - try: - with open(cache_loc, 'r', encoding="utf8") as F: - dct["codes"] = json.load(F) - except Exception as e: - raise Exception(f"Attempted to read cached Softcode definitions \'{main_filename}\', encountered error: {e}") from e + try: + with JSONHandler(cache_loc, f"Error reading '{main_filename}'") as data: + dct["codes"] = data + except json.decoder.JSONDecodeError as e: + print('error', e) + except Exception as e: + raise Exception(f"Attempted to read cached Softcode definitions \'{main_filename}\', encountered error: {e}") from e self.add_subcategory(category_def, dct["codes"]) @@ -206,28 +215,34 @@ def dump_codes_to_json(self): json.dump(subcat.get_data_as_serialisable(), F, separators=(',', ':')) - def wrap_strings(values): return ("\"{var}\"" for var in values) + def splat(values): return ",".join(values) + def splat_strings(values): return ",".join(wrap_strings(values)) + def as_list(values): return f"[{','.join(values)}]" + def as_list_strings(values): return f"[{','.join(wrap_strings(values))}]" + def as_braced_list(values): return f"{{{','.join(values)}}}" + def as_braced_list_strings(values): return f"{{{','.join(wrap_strings(values))}}}" - + + class SoftcodeListVariableCategoryDefinition: methods = { "splat": splat, @@ -245,7 +260,8 @@ def call_formatting_func( func_def, values, parent_value): @staticmethod def value_lambda(values): return f"[{','.join(wrap_strings(values))}]" - + + class SoftcodeListVariableCategory: __slots__ = ("keys",) definition = SoftcodeListVariableCategoryDefinition @@ -262,6 +278,7 @@ def get(self, key): def add_variable(self, name): self.keys[name] = SoftcodeListVariable() + class SoftcodeListVariable: __slots__ = ("value", "opcodes", "is_default") diff --git a/src/MainWindow.py b/src/MainWindow.py index f8758eb..3db7707 100644 --- a/src/MainWindow.py +++ b/src/MainWindow.py @@ -1,4 +1,3 @@ -import json import os from PyQt5 import QtCore @@ -8,6 +7,7 @@ from src.CoreOperations import CoreOperations from src.UI.Design import uiMainWidget from src.UI.StyleEngine import StyleEngine +from src.Utils.JSONHandler import JSONHandler translate = QtCore.QCoreApplication.translate @@ -52,7 +52,6 @@ def closeEvent(self, event): event.accept() #self.__app.quit() - @QtCore.pyqtSlot(str) def changeLanguage(self, qm_filename): if qm_filename: @@ -90,11 +89,10 @@ def retranslateUi(self): def loadLanguageOptions(self): out = {} if os.path.isdir(self.ops.paths.localisations_loc): - if os.path.isfile(self.ops.paths.localisations_names_loc): - - with open(self.ops.paths.localisations_names_loc, 'r', encoding="utf8") as F: - names = json.load(F) - else: + try: + with JSONHandler(self.ops.paths.localisations_names_loc, f"Error reading '{self.ops.paths.localisations_names_loc}'") as stream: + names = stream + except: self.ui.log(translate("UI::Localisation", "Language names file \"{filepath}\" was not found. Using default language names.").format(filepath=self.ops.paths.localisations_names_loc)) names = {} @@ -115,7 +113,7 @@ def loadLanguageOptions(self): def __init_ui(self): self.window = QtWidgets.QWidget() try: - self.icon = QtGui.QIcon(os.path.join("img", 'icon_256.png')) + self.icon = QtGui.QIcon(os.path.join('data', 'img', 'icon_256.png')) self.setWindowIcon(self.icon) except: pass @@ -127,7 +125,6 @@ def __init_core_operators(self): self.ops = CoreOperations(self) self.ops.mod_registry.update_mods() - def __set_language(self): lang = self.ops.config_manager.get_lang_pref() if lang is None: @@ -136,9 +133,6 @@ def __set_language(self): self.ui.log(translate("MainWindow", "Translation file for {language} was not found.").format(language=lang)) lang = "en-US" self.changeLanguage(lang + ".qm") - - - def __init_ui_hooks(self): self.ui.hook_menu(self.ops) @@ -185,7 +179,6 @@ def rehook_crash_handler(self, func): except: pass finally: self.raise_exception.connect(func) - @QtCore.pyqtSlot(Exception) def log_exception(self, exception): self.ui.log(exception.__str__()) @@ -209,4 +202,4 @@ def blocker_window(self): @QtCore.pyqtSlot() def quit_program(self): - self.close() \ No newline at end of file + self.close() diff --git a/src/UI/Design.py b/src/UI/Design.py index f8f37e9..cdec7e6 100644 --- a/src/UI/Design.py +++ b/src/UI/Design.py @@ -2,18 +2,19 @@ import copy from datetime import datetime -import os from .CustomWidgets import ClickEmitComboBox, DragDropTreeView, LinkItem translate = QtCore.QCoreApplication.translate + def safe_disconnect(func): try: func.disconnect() except TypeError: pass - + + ############################### # Top-Level Widget Containers # ############################### @@ -184,6 +185,7 @@ def generate_func(file): self.languageMenu.addAction(act) + class ColourThemeSelectionPopup(QtWidgets.QDialog): def __init__(self, parent): super().__init__(parent) @@ -318,10 +320,7 @@ def delete_theme(self): self.style_engine.delete_style(style_name) self.set_available_themes() - - - - + class CreateColourThemePopup(QtWidgets.QDialog): communicate_name_change = QtCore.pyqtSignal(str) @@ -438,8 +437,7 @@ def update_mergeable(self, checkbox, accessor, style, label, button): self.refresh_style() label.setEnabled(not is_mirroring) button.setEnabled(not is_mirroring) - - + def addColorSelector(self, label_text, row, accessor, style, layout, mergeable): label = QtWidgets.QLabel(label_text, self) button = QtWidgets.QPushButton("", self) @@ -618,6 +616,7 @@ def __init__(self, win): msgBox.exec_() + class supportPopup: def __init__(self, win, paths): msgBox = QtWidgets.QMessageBox(win) @@ -637,7 +636,6 @@ def __init__(self, win, paths): msgBox.exec_() - class uiMainArea: def __init__(self, parentWidget): self.define(parentWidget) @@ -652,7 +650,8 @@ def define(self, parentWidget): def lay_out(self): self.layout.addLayout(self.mod_interaction_area.layout, 0, 0) self.layout.addLayout(self.action_tabs.layout, 0, 1) - + + class uiLoggingArea: def __init__(self, parentWidget): self.define(parentWidget) @@ -793,8 +792,7 @@ def retranslateUi(self): self.md_model.setHorizontalHeaderLabels([translate("UI::ModsDisplay", "Name"), translate("UI::ModsDisplay", "Author"), translate("UI::ModsDisplay", "Version"), translate("UI::ModsDisplay", "Category")]) self.reinstallModAction.setText(translate("UI::ModRightClickMenu", "Re-register...")) self.deleteModAction.setText(translate("UI::ModRightClickMenu", "Delete Mod")) - - + def lay_out(self): self.layout.addWidget(self.mods_display, 0, 0) @@ -846,6 +844,7 @@ def update_mod_info_window(self, func): def hook_itemchanged(self, func): self.mods_display.model.itemChanged.connect(func) + class uiModInstallationWidgets: def __init__(self, parentWidget): self.define(parentWidget) @@ -911,8 +910,7 @@ def define(self, parentWidget): self.logview.setAlternatingRowColors(True) self.logview.setWordWrap(True) self.logview.setMinimumHeight(110) - - + def lay_out(self): self.layout.addWidget(self.logview, 0, 0) @@ -953,7 +951,8 @@ def cull_messages(self): if self.logview.count() >= self.max_items: for i in range(self.logview.count() - self.max_items + 1): self.logview.takeItem(0) - + + ########################## # Action Tabs Containers # ########################## @@ -1006,7 +1005,8 @@ def toggle_active(self, active): self.configTab.toggle_active(active) self.extractTab.toggle_active(active) self.conflictsTab.toggle_active(active) - + + class uiModInfoTab(QtWidgets.QWidget): def __init__(self, parentWidget): super().__init__() @@ -1033,7 +1033,8 @@ def toggle_active(self, active): def hook(self): pass - + + class ModInfoRegion: def __init__(self, parentWidget): super().__init__() @@ -1103,6 +1104,7 @@ def setModInfo(self, name, folder, author, version, desc): self.setModDesc(desc) self.retranslateUi() + class uiConfigTab(QtWidgets.QWidget): def __init__(self, parentWidget): @@ -1142,13 +1144,13 @@ def define(self, parentWidget): self.purge_resources_button = QtWidgets.QPushButton(parentWidget) self.purge_resources_button.setFixedWidth(120) - self.crash_handle_layout = QtWidgets.QHBoxLayout(parentWidget) + self.crash_handle_layout = QtWidgets.QHBoxLayout() self.crash_handle_label = QtWidgets.QLabel() self.crash_handle_label.setFixedWidth(120) self.crash_handle_box = QtWidgets.QComboBox(parentWidget) self.crash_handle_box.setFixedWidth(120) - self.block_handle_layout = QtWidgets.QHBoxLayout(parentWidget) + self.block_handle_layout = QtWidgets.QHBoxLayout() self.block_handle_label = QtWidgets.QLabel() self.block_handle_label.setFixedWidth(120) self.block_handle_box = QtWidgets.QComboBox(parentWidget) @@ -1214,8 +1216,7 @@ def hook(self, find_gamelocation, update_dscstools, purge_softcodes, purge_indic self.crash_handle_box.addItem(option) for option in block_handler_operations(): self.block_handle_box.addItem(option) - - + def enable(self): self.toggle_active(True) @@ -1232,7 +1233,8 @@ def toggle_active(self, active): self.purge_resources_button.setEnabled(active) self.crash_handle_box.setEnabled(active) self.block_handle_box.setEnabled(active) - + + class uiExtractTab(QtWidgets.QScrollArea): def __init__(self, parentWidget): self.data_mvgls = ["DSDB", "DSDBA", "DSDBS", "DSDBSP", "DSDBP"] @@ -1515,5 +1517,3 @@ def disable(self): def toggle_active(self, active): self.conflicts_graph.setEnabled(active) - - \ No newline at end of file diff --git a/src/UI/StyleEngine.py b/src/UI/StyleEngine.py index fca0c1c..7beb448 100644 --- a/src/UI/StyleEngine.py +++ b/src/UI/StyleEngine.py @@ -4,8 +4,11 @@ from PyQt5 import QtCore, QtGui, QtWidgets +from src.Utils.JSONHandler import JSONHandler + translate = QtCore.QCoreApplication.translate + class PaletteColour: __slots__ = ("c", "mirror_inactive") @@ -47,7 +50,8 @@ def to_dict(self): "c": [self.c.red(), self.c.green(), self.c.blue()], "mirror_inactive": self.mirror_inactive } - + + class SubPalette: __slots__ = ("window", "base", "alt_base", "button", "bright_text", "text", "window_text", "button_text", @@ -56,6 +60,7 @@ class SubPalette: "tooltip_base", "tooltip_text", "light", "midlight", "mid", "dark", "shadow", "unified_text") + def __init__(self): self.window = PaletteColour() self.base = PaletteColour() @@ -108,7 +113,8 @@ def to_dict(self): else getattr(self, key) for key in self.__slots__} - + + class PaletteMap: __slots__ = ("active", "inactive", "disabled") def __init__(self): @@ -132,6 +138,7 @@ def from_dict(cls, dct): def to_dict(self): return {key: getattr(self, key).to_dict() for key in self.__slots__} + class StyleEngine: """ Dark Colours from https://stackoverflow.com/a/56851493 @@ -158,7 +165,6 @@ def __init__(self, app_ref, paths, log, initial_style=None): .format(initial_style=initial_style, default_theme=default_theme)) self.set_style(default_theme) - @property def light_style(self): pmap = PaletteMap() @@ -406,8 +412,8 @@ def load_style(self, file): filename, ext = os.path.splitext(file) if os.path.isfile(filepath) and ext.lstrip(os.extsep) == "json": try: - with open(filepath, 'r') as F: - style_def = json.load(F) + with JSONHandler(filepath, f"Error reading '{file}'") as stream: + style_def = stream name = filename self.styles[name] = PaletteMap.from_dict(style_def) except Exception as e: @@ -415,7 +421,6 @@ def load_style(self, file): "WARNING: Failed to load style file '{name}.json'. Error is: {error_msg}.") .format(name=filename, error_msg=e)) - def save_style(self, name): filepath = os.path.join(self.__paths.themes_loc, os.extsep.join((name, "json"))) with open(filepath, 'w') as F: diff --git a/src/Utils/JSONHandler.py b/src/Utils/JSONHandler.py new file mode 100644 index 0000000..fa439d6 --- /dev/null +++ b/src/Utils/JSONHandler.py @@ -0,0 +1,21 @@ +import json +import os + + +class JSONHandler: + def __init__(self, filename, message, **decode_kwargs): + self.filename = filename + self.message = message + self.decode_kwargs = decode_kwargs + + def __enter__(self): + if not os.path.exists(self.filename): + raise FileNotFoundError(f'JSON file {self.filename} does not exist') + try: + with open(self.filename, 'r', encoding="utf-8") as stream: + return json.load(stream, **self.decode_kwargs) + except json.decoder.JSONDecodeError as e: + raise json.decoder.JSONDecodeError(f'{self.message}: {str(e)}') from e + + def __exit__(self, exc_type, exc_value, exc_traceback): + pass diff --git a/src/Utils/MdlEditImpl.py b/src/Utils/MdlEditImpl.py index b54601a..3e9c1e4 100644 --- a/src/Utils/MdlEditImpl.py +++ b/src/Utils/MdlEditImpl.py @@ -1,9 +1,9 @@ import json -import os from src.Utils.Settings import default_encoding from src.Utils.Softcodes import replace_softcodes + class MdlEdit: ops = ["editNPC"] __slots__ = tuple(ops)