diff --git a/.gitignore b/.gitignore index 7078ceb..6dbe571 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .pylint_cache *.pyc **/__pycache__/* -output \ No newline at end of file +output +experiment* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5bd52f1..7db8889 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,4 +4,11 @@ RUN apt update && apt -y install vim COPY ./requirements.txt . -RUN wine pip install --no-warn-script-location -r requirements.txt \ No newline at end of file +# atomicwrites won't build without the --use-pep517 flag +# And atomicwrites is required by pywinauto +RUN wine pip install --no-warn-script-location --use-pep517 -r requirements.txt + +# https://stackoverflow.com/questions/76583866/pywinauto-wine-and-docker-attributeerror-module-comtypes-gen-has-no-attrib +# ultra ugly solution to remove the UIA backend support from pywinauto +# the other way would be to find a way to install that missing backend through wine +RUN sed -i '34,38d' /opt/wineprefix/drive_c/Python310/Lib/site-packages/pywinauto/controls/__init__.py diff --git a/Makefile b/Makefile index cde7fa2..66fd845 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ all: local-container # run locally local-container: @echo "Building local images..." - ${DOCKER_COMPOSE} build wine tinker >/dev/null 2>&1 + ${DOCKER_COMPOSE} build wine tinker > /dev/null 2>&1 tinker: local-container ${TINKER} diff --git a/README.md b/README.md index 71e3499..849cc63 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ The latter will drop you off in a shell with current directory (this repository) From there on, to get a python shell - run `wine python3` After that, to import settings module: `import src.config.settings` +### If you don't want docker +Use your own python and install venv. requirements.txt is in the project root +I strongly recommend learning to use docker. In this setup everything is automated (see above section for using docker and make) +Docker is a powerful tool for development that allows to seamlessly switch project components +and dependencies or even the operating system in minutes and go back instantly +With Makefile and docker-compose like in this project you don't have to switch between venvs (or anything else for that matter) + ### Notes #### Issues with volume mount path conversion To avoid those do `export MSYS_NO_PATHCONV=1` before doing anything diff --git a/build.sh b/build.sh index 1e4006b..f24d721 100644 --- a/build.sh +++ b/build.sh @@ -1,6 +1,14 @@ #!/bin/bash set -e + +# Run pylint on each file just to make sure there are not syntax errors +# Pyinstaller doesn't care about syntax errors so if there are any - it will be silent +echo "Checking for syntax errors before building..." +wine pylint --errors-only --fail-on=F src +echo "No syntax errors found, building!" + rm -rf output + wine pyinstaller --clean --windowed --name tqma --specpath /tmp/spec --distpath /tmp/dist --workpath /tmp/work ${WORKDIR}/src/main.py mkdir output cp -R /tmp/dist/tqma/* output diff --git a/requirements.txt b/requirements.txt index e055c11..3cccad4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ requests==2.24.0 #black=20.8b1 PyQt6==6.5.0 pyinstaller==5.13.0 +pywinauto==0.6.8 prospector[with_everything]==1.7.7 \ No newline at end of file diff --git a/src/art_manager/README.md b/src/art_manager/README.md deleted file mode 100644 index 848733c..0000000 --- a/src/art_manager/README.md +++ /dev/null @@ -1 +0,0 @@ -# Art manager tool automation diff --git a/src/binary_automation/README.md b/src/binary_automation/README.md new file mode 100644 index 0000000..d619019 --- /dev/null +++ b/src/binary_automation/README.md @@ -0,0 +1 @@ +# Binary tool automation code diff --git a/src/binary_automation/__init__.py b/src/binary_automation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/binary_automation/art_manager.py b/src/binary_automation/art_manager.py new file mode 100644 index 0000000..0a329fb --- /dev/null +++ b/src/binary_automation/art_manager.py @@ -0,0 +1,42 @@ +""" +The code for art manager automation +""" +import logging +import os + +from pywinauto.application import Application + +logger = logging.getLogger("tqma") + + +class ArtManager: + """ Represents art manager tool from official TQAE install path """ + def __init__(self, installation_path, settings_path): + logger.debug("Instantiating art manager object") + self.path = os.path.join(installation_path, "ArtManager.exe") + self.tools_ini = self.read_tools_ini(os.path.join(settings_path, "Tools.ini")) + + def read_tools_ini(self, tools_ini_path): + """ Reads tools_ini and extracts some important settings related to work/build paths """ + parsed_settings = {} + logger.debug(f"Tools.ini path: {tools_ini_path}") + with open(tools_ini_path, 'r', encoding='utf-8') as tools_ini: + for line in tools_ini.readlines(): + if "=" in line: + parsed_settings[line.split("=")[0]] = line.strip().split("=")[1] + logger.debug(f"Working dir: {parsed_settings['localdir']}") + logger.debug(f"Build dir: {parsed_settings['builddir']}") + logger.debug(f"Tools dir: {parsed_settings['toolsdir']}") + + return parsed_settings + + def run(self): + """ Runs the ArtManager executable """ + logger.debug("Starting art manager") + app = Application(backend="win32").start(self.path) + logger.debug(f"{app}") + + def build(self, output_mod_name): + """ Builds the selected mod """ + logger.debug(f"Building mod {output_mod_name}:)") + self.run() diff --git a/src/dbr_lib.py b/src/dbr_lib.py new file mode 100644 index 0000000..c837e52 --- /dev/null +++ b/src/dbr_lib.py @@ -0,0 +1,20 @@ +""" DBR operations library """ + +def parse_dbr(dbr_file): + """ Takes a file path as an input and parses it as a dbr. Returns dictionary with DBR data""" + dbr_data = {} + with open(dbr_file, 'r', encoding='utf-8') as dbr_contents: + for line in dbr_contents.readlines(): + dbr_data[line.split(',', 1)[0]] = line.split(',', 1)[1].strip() + + return dbr_data + +def write_dbr(dbr_data, output_file): + """ Takes a dictionary with DBR data and writes it into an output file as a dbr """ + contents = "" + for key, value in dbr_data.items(): + contents += key + "," + value + "\n" + + with open(output_file, 'w', encoding='utf-8') as dbr_contents: + dbr_contents.write(contents) + \ No newline at end of file diff --git a/src/gui/frames/mod_merge_frame.py b/src/gui/frames/mod_merge_frame.py index 02402e8..fa375f8 100644 --- a/src/gui/frames/mod_merge_frame.py +++ b/src/gui/frames/mod_merge_frame.py @@ -3,11 +3,17 @@ """ import logging import os +import re +import traceback -from PyQt6.QtWidgets import QListWidget, QAbstractItemView, QPushButton, QLabel -from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QListWidget, QAbstractItemView, QPushButton, QLabel, QLineEdit +from PyQt6.QtCore import Qt, QEventLoop from src.gui.frames.common_frame import CommonFrame from src.mods import Mod +from src.binary_automation.art_manager import ArtManager +from src.mod_merge import ModMerge +from src.gui.windows.conflict_resolution_window import ConflictResolutionWindow + logger = logging.getLogger("tqma") @@ -18,11 +24,14 @@ def __init__(self, parent, settings): super().__init__(parent, settings, objectName="Mod merge") self.mods = [] self.selected_mods = [] + self.good_style_sheet = None + self.bad_style_sheet = "border: 1px solid red;" self.layout().setSpacing(0) # Mod list label mod_list_tooltip = "Use CTRL and SHIFT to select multiple" self.mod_list_label = QLabel("Choose mods:") + self.good_style_sheet = self.mod_list_label.styleSheet() self.mod_list_label.setToolTip(mod_list_tooltip) self.layout().addWidget(self.mod_list_label, 0, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop) @@ -34,8 +43,19 @@ def __init__(self, parent, settings): self.mod_list.itemSelectionChanged.connect(self.on_mod_list_change) self.layout().addWidget(self.mod_list, 1, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop) + # New mod name input label and field + new_mod_tooltip = "Mod name should consist of: a-zA-Z0-9_- and space character" + self.new_mod_name_label = QLabel("Merged mod name") + self.new_mod_name_label.setToolTip(new_mod_tooltip) + self.layout().addWidget(self.new_mod_name_label, 2, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop) + self.new_mod_name_field = QLineEdit(self) + self.new_mod_name_field.setToolTip(new_mod_tooltip) + self.new_mod_name_field.textChanged.connect(self.new_mod_name_changed) + self.layout().addWidget(self.new_mod_name_field, 3, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop) + # Build button build_button = QPushButton('Build') + build_button.clicked.connect(self.merge_mods) self.layout().addWidget(build_button, 4, 1, alignment=Qt.AlignmentFlag.AlignBottom) # This moves thigs up and left a little so that it's a bit more cramped @@ -44,6 +64,22 @@ def __init__(self, parent, settings): self.show() + def resolve_conflicts(self, overlaps): + """ Generates and shows settings window from which the settings can be edited """ + + logger.debug("Calling creation of conflict resolution window") + + conflict_resolution_window = ConflictResolutionWindow(overlaps) + conflict_resolution_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + conflict_resolution_window.setWindowModality(Qt.WindowModality.ApplicationModal) + conflict_resolution_window.show() + + logger.debug("Waiting for conflict resolution") + wait_loop = QEventLoop() + conflict_resolution_window.destroyed.connect(wait_loop.quit) + wait_loop.exec() + logger.debug("Conflict resolution complete") + def load_mod_list(self): """ Loads an instance of Mod for all directories in settings["Mod sources path"] """ if not self.settings.get_setting("Mod sources path"): @@ -59,9 +95,20 @@ def load_mod_list(self): self.mod_list.clear() self.mod_list.addItems([mod.name for mod in self.mods]) + def lock_controls(self): + """ Lock all the interactable controls within the frame """ + + def new_mod_name_changed(self): + """ Triggered by new_mod_name_field being changed by the user """ + if self.validate_new_mod_name(): + self.new_mod_name_field.setStyleSheet(self.good_style_sheet) + else: + self.new_mod_name_field.setStyleSheet(self.bad_style_sheet) + def on_mod_list_change(self): """ Stores selected mods when mod list gets changed by user""" self.selected_mods = [] + self.mod_list.setStyleSheet(self.good_style_sheet) selected_items = [item.text() for item in self.mod_list.selectedItems()] for selected_item in selected_items: for mod in self.mods: @@ -69,3 +116,46 @@ def on_mod_list_change(self): self.selected_mods.append(mod) logger.debug("Currently selected mods: %s", [mod.name for mod in self.selected_mods]) + + def merge_mods(self): + """ Merges selected mods with selected options using artmanager """ + try: + if not self.selected_mods: + self.mod_list.setStyleSheet(self.bad_style_sheet) + logger.debug("No mods selected so can't really merge anything!") + return + if not self.validate_new_mod_name(): + logger.debug("Provided mod name is either empty or doesn't conform to the naming regex") + self.new_mod_name_field.setStyleSheet(self.bad_style_sheet) + return + + art_manager = ArtManager( + installation_path = self.settings.get_setting("TQAE path"), + settings_path = self.settings.get_setting("TQAE Save folder") + ) + + mod_merge = ModMerge( + output_path=art_manager.tools_ini['localdir'], + new_mod_name=self.new_mod_name_field.text() + ) + + mod_merge.set_mods(self.selected_mods) + mod_merge.process_database_files() + if mod_merge.conflicts: + logger.debug("Resolving conflicts...") + self.resolve_conflicts(mod_merge.overlaps) + mod_merge.merge() + + art_manager.build(self.new_mod_name_field.text()) + + except Exception as exc: + traceback_formatted = traceback.format_exc() + logger.debug(traceback_formatted) + raise RuntimeError(traceback_formatted) from exc + + def validate_new_mod_name(self): + """ Validates the mod name to be correct so that later a directory can be created with that name """ + if re.match('^[a-zA-Z0-9_-]+$', self.new_mod_name_field.text()): + return True + + return False diff --git a/src/gui/windows/conflict_resolution_window.py b/src/gui/windows/conflict_resolution_window.py new file mode 100644 index 0000000..e9ba1c2 --- /dev/null +++ b/src/gui/windows/conflict_resolution_window.py @@ -0,0 +1,108 @@ +""" +The code for all the windows that TQMA has +""" +import logging +import os + +from PyQt6.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QGroupBox, QGridLayout +from PyQt6.QtCore import Qt + +from src.dbr_lib import parse_dbr + + +logger = logging.getLogger("tqma") + + +class ConflictResolutionWindow(QWidget): + """ Settings window to configure build/working paths, mod paths etc. """ + def __init__(self, overlaps): + super().__init__() + self.currently_processed_overlap = None + self.currently_processed_key = None + self.number_of_conflicts_left = 0 + + # The layout + layout = QGridLayout(self) + layout.setSpacing(15) + self.setLayout(layout) + + # Make a duplicate list of objects because we will be popping them + self.overlaps = overlaps.copy() + for overlap in self.overlaps: + if not overlap.conflicting_keys: + self.overlaps.remove(overlap) + + # Count total number of conflicts + for overlap in self.overlaps: + self.number_of_conflicts_left += len(overlap.conflicting_keys) + + # Start the conflict resolution right from init + self.resolve_next_overlap() + + def clear_layout(self, layout): + """ Clears layout """ + if layout is not None: + while layout.count(): + child = layout.takeAt(0) + if child.widget() is not None: + child.widget().deleteLater() + elif child.layout() is not None: + self.clear_layout(child.layout()) + + def process_resolution_choice(self, choice): + """ This method is called when user presses the button for one of conflict value choices """ + logger.debug(f"Resolution choice: {choice}") + self.currently_processed_overlap.resolved[self.currently_processed_key] = choice + self.clear_layout(self.layout()) + self.number_of_conflicts_left -= 1 + self.resolve_next_overlap() + + def resolve_next_overlap(self): + """ + Pops next overlap from overlaps and runs conflict resolution for it + If there are no overlaps left the conflict resolution window is closed + """ + if not self.overlaps and not self.currently_processed_overlap.conflicting_keys: + logger.debug("No overlaps or conflicting keys left to process, closing") + self.close() + return + + if not self.currently_processed_overlap or not self.currently_processed_overlap.conflicting_keys: + self.currently_processed_overlap = self.overlaps.pop() + + self.currently_processed_key = self.currently_processed_overlap.conflicting_keys.pop() + self.run_conflict_resolution(self.currently_processed_overlap, self.currently_processed_key) + + def run_conflict_resolution(self, overlap, conflicting_key): + """ Takes a single overlap, generates widgets with choices for conflicting key(s) and stores user input """ + group_box_x_position = 2 + + self.setWindowTitle(f"TQMA conflicts left: {self.number_of_conflicts_left}") + logger.debug( + f"Processing {overlap.dbr_relpath} {conflicting_key}, conflicts left: {self.number_of_conflicts_left}" + ) + + conflict_dbr_info_label = QLabel(f"Resolving conflict for dbr {overlap.dbr_relpath}", self) + self.layout().addWidget(conflict_dbr_info_label, 0, 0, 2, 1, alignment=Qt.AlignmentFlag.AlignTop) + conflict_key_info_label = QLabel(f"Conflicting key: {conflicting_key}", self) + self.layout().addWidget(conflict_key_info_label, 1, 0, 2, 1, alignment=Qt.AlignmentFlag.AlignTop) + + for mod in overlap.mods: + group_box = QGroupBox(self) + group_box_layout = QHBoxLayout() + group_box_layout.setSpacing(10) + group_box.setLayout(group_box_layout) + + dbr_data = parse_dbr(os.path.join(mod.path, overlap.dbr_relpath)) + # label with mod name + mod_name_label = QLabel(f"Mod name: {mod.name}", self) + group_box.layout().addWidget(mod_name_label) + # button with attribute value + value_button = QPushButton(f"{dbr_data[conflicting_key]}") + value_button.clicked.connect( + lambda checked, choice=dbr_data[conflicting_key]: self.process_resolution_choice(choice) + ) + group_box.layout().addWidget(value_button) + + self.layout().addWidget(group_box, group_box_x_position, 0, 2, 1, alignment=Qt.AlignmentFlag.AlignTop) + group_box_x_position += 1 diff --git a/src/gui/windows/main_window.py b/src/gui/windows/main_window.py index 5aba1f9..cee00da 100644 --- a/src/gui/windows/main_window.py +++ b/src/gui/windows/main_window.py @@ -94,7 +94,6 @@ def show_frame_by_name(self, frame_name): for frame in self.frames: frame.hide() - #frame_classes = (ModMergeFrame, ModManagerFrame) frame_to_show = self.findChild(QWidget, name=frame_name) logger.debug(f"Found frame: {frame_to_show}") if frame_to_show: diff --git a/src/mod_manager/README.md b/src/mod_manager/README.md deleted file mode 100644 index 78441f1..0000000 --- a/src/mod_manager/README.md +++ /dev/null @@ -1 +0,0 @@ -# A mod manager tool diff --git a/src/mod_merge.py b/src/mod_merge.py new file mode 100644 index 0000000..98f1e32 --- /dev/null +++ b/src/mod_merge.py @@ -0,0 +1,146 @@ +""" +The code for mod merging +""" +import logging +import os +import shutil + +from src.mods import Mod +from src.dbr_lib import parse_dbr, write_dbr + +logger = logging.getLogger("tqma") + + +class DbrOverlap: + """ Represents an overlap of a dbr across at least 2 mods """ + def __init__(self, dbr_relpath): + self.conflicting_keys = [] + self.dbr_relpath = dbr_relpath + self.mods = None + self.no_conflict = {} + self.resolved = {} + + def add_conflicting_key(self, key): + """ Adds a conflicting key and makes sure all of them are unique """ + if key not in self.conflicting_keys: + self.conflicting_keys.append(key) + + +class ModMerge: + """ Represents mod merging interface """ + def __init__(self, output_path, new_mod_name): + self.conflicts = False + self.mods_to_merge = [] + self.output_path = output_path + self.overlaps = [] + + self.new_mod = Mod(os.path.join(self.output_path, "CustomMaps", new_mod_name), create=True) + + def process_overlaps(self): + """ Finds all conflicts in overlaps and stores non-conflicting values for later marge """ + + # for each .dbr that is in at least 2 mods + for overlap in self.overlaps: + # here we get the list of mods which have the same file listed + logger.debug(f"Processing overlap: {overlap.dbr_relpath}") + mods_with_overlap = [ + mod for mod in self.mods_to_merge if overlap.dbr_relpath in mod.files["database"] + ] + overlap.mods = mods_with_overlap + list_of_dbr_data_dicts = [] + for mod in mods_with_overlap: + dbr_data = parse_dbr(os.path.join(mod.path, overlap.dbr_relpath)) + list_of_dbr_data_dicts.append(dbr_data) + + # go through all of the combinations of key/value for the DBRs + # and find all such keys that have a different value in at least one case + for dict_x in list_of_dbr_data_dicts: + for dict_y in list_of_dbr_data_dicts: + if dict_x is dict_y: + continue + for k_x, v_x in dict_x.items(): + if k_x in dict_y and dict_y[k_x] != v_x: + overlap.add_conflicting_key(k_x) + self.conflicts = True + + for dbr_data_dict in list_of_dbr_data_dicts: + for key, value in dbr_data_dict.items(): + if key not in overlap.conflicting_keys: + overlap.no_conflict[key] = value + + logger.debug(f"Overlap conflicting keys: {overlap.conflicting_keys}") + + def merge(self): + """ Aggregator method for all types of files that are to be merged """ + logger.debug(f"Merging mods {[mod.name for mod in self.mods_to_merge]} into {self.new_mod.name}") + self.merge_database_files() + self.merge_asset_files() + self.merge_source_files() + + def merge_asset_files(self): + """ Merge asssets folder. Right now it just chooses first one and copies """ + logger.debug(f"Merging assets for {self.new_mod.name}") + for asset in self.mods_to_merge[0].files["assets"]: + new_mod_asset = os.path.normpath(os.path.join(self.new_mod.path, asset)) + source_asset_path = os.path.join(self.mods_to_merge[0].path, asset) + logger.debug(f"Copying asset {source_asset_path} into {new_mod_asset}") + os.makedirs(os.path.dirname(new_mod_asset), exist_ok=True) + shutil.copyfile(source_asset_path, new_mod_asset) + self.new_mod.files["assets"].append(asset) + + def merge_database_files(self): + """ Merge asssets folder. Right now it just chooses first one and copies """ + + list_of_overlaps = [overlap.dbr_relpath for overlap in self.overlaps] + + for mod in self.mods_to_merge: + for dbr in mod.files["database"]: + if dbr not in self.new_mod.files["database"] and dbr not in list_of_overlaps: + new_mod_dbr_path = os.path.normpath(os.path.join(self.new_mod.path, dbr)) + source_dbr_path = os.path.join(mod.path, dbr) + os.makedirs(os.path.dirname(new_mod_dbr_path), exist_ok=True) + shutil.copyfile(source_dbr_path, new_mod_dbr_path) + self.new_mod.files["database"].append(dbr) + + if self.overlaps: + for overlap in self.overlaps: + dbr_data = {} + dbr_data.update(overlap.no_conflict) + dbr_data.update(overlap.resolved) + write_dbr(dbr_data, os.path.normpath(os.path.join(self.new_mod.path, overlap.dbr_relpath))) + self.new_mod.files["database"].append(overlap.dbr_relpath) + + def process_database_files(self): + """ Merge database folder """ + logger.debug(f"Processing database files for {self.new_mod.name}") + intersections = [] + list_of_mod_database_file_lists = [mod.files["database"] for mod in self.mods_to_merge] + + for list_x in list_of_mod_database_file_lists: + for list_y in list_of_mod_database_file_lists: + if list_x is list_y: + continue + intersection = list(set(list_x).intersection(list_y)) + intersections.extend(intersection) + intersections = list(set(intersections)) + logger.debug(f"Intersections: {intersections}") + if intersections: + for intersection in intersections: + self.overlaps.append(DbrOverlap(intersection)) + + self.process_overlaps() + + def merge_source_files(self): + """ Merge source folder """ + logger.debug(f"Merging sources for {self.new_mod.name}") + for source in self.mods_to_merge[0].files["source"]: + new_mod_source = os.path.normpath(os.path.join(self.new_mod.path, source)) + source_source_path = os.path.join(self.mods_to_merge[0].path, source) + logger.debug(f"Copying source {source_source_path} into {new_mod_source}") + os.makedirs(os.path.dirname(new_mod_source), exist_ok=True) + shutil.copyfile(source_source_path, new_mod_source) + self.new_mod.files["source"].append(source) + + def set_mods(self, mods): + """ Set the mods that are going to be merged """ + self.mods_to_merge = mods diff --git a/src/mod_merge/README.md b/src/mod_merge/README.md deleted file mode 100644 index 69e6732..0000000 --- a/src/mod_merge/README.md +++ /dev/null @@ -1 +0,0 @@ -# A mod merge tool diff --git a/src/mods.py b/src/mods.py index 71536f7..a103e02 100644 --- a/src/mods.py +++ b/src/mods.py @@ -3,12 +3,52 @@ """ import logging import os +import traceback logger = logging.getLogger("tqma") class Mod: """ Represents a single mod. Input for instantiation is a folder with the said mod """ - def __init__(self, mod_path): + def __init__(self, mod_path, create=False): + self.files = {} + self.mod_directories = ["assets", "database", "source"] self.path = mod_path self.name = os.path.basename(mod_path) + if create: + self.setup_directory_structure() + else: + self.files = self.read_files() + + def read_files(self): + """ Calls all files to be read for the mod """ + files = {} + + for directory in self.mod_directories: + files[directory] = [os.path.normpath(os.path.join(os.path.relpath(d, self.path), x)) + for d, dirs, files in os.walk(os.path.join(self.path, directory)) + for x in files] + + logger.debug( + f""" + Mod file count for {self.name}: + {len(files["assets"])} assets, {len(files["database"])} database, {len(files["source"])} source + """ + ) + + return files + + def setup_directory_structure(self): + """ Create output mod's directory structure""" + logger.debug(f"Creating directory structure for {self.name}...") + for directory in self.mod_directories: + try: + new_dir = os.path.normpath(os.path.join(self.path, directory)) + logger.debug(f"Creating directory '{new_dir}'") + os.makedirs(new_dir, exist_ok=True) + self.files[directory] = [] + except Exception as exc: + traceback_formatted = traceback.format_exc() + logger.debug(traceback_formatted) + raise RuntimeError(traceback_formatted) from exc + logger.debug("Created!")