From 5d92a5c5dff019821a73a15dbcfc3e54ec5e39d9 Mon Sep 17 00:00:00 2001 From: cyberrumor Date: Fri, 13 Dec 2024 13:03:26 -0800 Subject: [PATCH] Further generalizations to controller.{mod,fomod} Previously, controller.fomod relied on bethesda-specific data, like the presence of a data directory. In order to remove this dependency, refactor controller.fomod so it relies on a new mod.fomod_target dir instead of the ammo_fomod/Data dir. Add a BethesdaMod dataclass so Bethesda fomods and other fomods can have a different fomod_target directory. This is needed since all Bethesda fomods will expect files which are output of fomod configuration wizards to be deployed under the game's Data directory. Non-bethesda games will simply deploy to the game's base directory. Remove unused enums from ammo/component.py. controller.fomod should be responsible for writing to /ammo_fomod, and controller.mod should be responsible for reading /ammo_fomod. As such, the responsibility of deleting that directory was moved to controller.fomod. --- ammo/component.py | 109 +++++++++++------- ammo/controller/bethesda.py | 38 +++++- ammo/controller/fomod.py | 26 +++-- ammo/controller/mod.py | 59 ++++------ test/bethesda/common.py | 12 +- test/bethesda/test_compatibility_fomod.py | 66 +++++------ .../test_fomod_controller_instance.py | 2 +- 7 files changed, 182 insertions(+), 130 deletions(-) diff --git a/ammo/component.py b/ammo/component.py index 5715507..4aa3a4a 100755 --- a/ammo/component.py +++ b/ammo/component.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import os from typing import Union -from enum import Enum from pathlib import Path from dataclasses import ( dataclass, @@ -9,28 +8,10 @@ ) -class Component(str, Enum): - MOD = "mod" - DOWNLOAD = "download" - - -class BethesdaComponent(str, Enum): - MOD = "mod" - DOWNLOAD = "download" - PLUGIN = "plugin" - - -class BethesdaComponentActivatable(str, Enum): - MOD = "mod" - PLUGIN = "plugin" - - @dataclass(slots=True, kw_only=True) class Mod: - # Generic attributes location: Path game_root: Path - game_data: Path name: str = field(default_factory=str, init=False) visible: bool = field(init=False, default=True, compare=False) install_dir: Path = field(init=False) @@ -38,14 +19,58 @@ class Mod: conflict: bool = field(init=False, default=False) obsolete: bool = field(init=False, default=True) files: list[Path] = field(default_factory=list, init=False) - # Bethesda attributes modconf: Union[None, Path] = field(init=False, default=None) fomod: bool = field(init=False, default=False) + fomod_target: Path = field(init=False, default=False) + + def __post_init__(self) -> None: + self.name = self.location.name + self.install_dir = self.game_root + self.fomod_target = Path("ammo_fomod") + + # Explicitly set self.files to an empty list in case we're rereshing + # files via manually calling __post_init__. + self.files = [] + # Scan the surface level of the mod to determine whether this is a fomod. + for file in self.location.iterdir(): + if file.is_dir() and file.name.lower() == "fomod": + # Assign ModuleConfig.xml. Only check surface of fomod folder. + for f in file.iterdir(): + if f.name.lower() == "moduleconfig.xml" and f.is_file(): + self.modconf = f + self.fomod = True + self.install_dir = self.game_root + break + if self.fomod: + break + + # Determine which folder to populate self.files from. For fomods, only + # care about files inside of an ammo_fomod folder. + location = self.location + if self.fomod: + location /= "ammo_fomod" + + if not location.exists(): + # No files to populate + return + + # Populate self.files + for parent_dir, _, files in os.walk(location): + for file in files: + f = Path(file) + loc_parent = Path(parent_dir) + self.files.append(loc_parent / f) + + +@dataclass(kw_only=True, slots=True) +class BethesdaMod(Mod): + game_data: Path plugins: list[str] = field(default_factory=list, init=False) def __post_init__(self) -> None: self.name = self.location.name self.install_dir = self.game_data + self.fomod_target = Path("ammo_fomod") / self.game_data.name # Explicitly set self.files to an empty list in case we're rereshing # files via manually calling __post_init__. self.files = [] @@ -75,32 +100,34 @@ def __post_init__(self) -> None: self.install_dir = self.game_root # Determine which folder to populate self.files from. For fomods, only - # care about files inside of an ammo_fomod/self.game_data.name folder + # care about files inside of an ammo_fomod folder # which may or may not exist. location = self.location if self.fomod: location /= "ammo_fomod" - # Populate self.files - if location.exists(): - for parent_dir, _, files in os.walk(location): - for file in files: - f = Path(file) - loc_parent = Path(parent_dir) - self.files.append(loc_parent / f) - - # populate plugins - plugin_dir = location - - for i in location.iterdir(): - if i.name.lower() == self.game_data.name.lower(): - plugin_dir /= i.name - break - - if plugin_dir.exists(): - for f in plugin_dir.iterdir(): - if f.suffix.lower() in (".esp", ".esl", ".esm") and not f.is_dir(): - self.plugins.append(f) + if not location.exists(): + # No files to populate + return + + for parent_dir, _, files in os.walk(location): + for file in files: + f = Path(file) + loc_parent = Path(parent_dir) + self.files.append(loc_parent / f) + + # populate plugins + plugin_dir = location + + for i in location.iterdir(): + if i.name.lower() == self.game_data.name.lower(): + plugin_dir /= i.name + break + + if plugin_dir.exists(): + for f in plugin_dir.iterdir(): + if f.suffix.lower() in (".esp", ".esl", ".esm") and not f.is_dir(): + self.plugins.append(f) @dataclass(kw_only=True, slots=True) diff --git a/ammo/controller/bethesda.py b/ammo/controller/bethesda.py index 629e115..7f1913b 100755 --- a/ammo/controller/bethesda.py +++ b/ammo/controller/bethesda.py @@ -13,14 +13,17 @@ dataclass, field, ) +from ammo.component import ( + BethesdaMod, + Download, + Plugin, +) from .mod import ( ModController, Game, ) -from ammo.component import ( - Mod, - Download, - Plugin, +from ammo.lib import ( + NO_EXTRACT_DIRS, ) log = logging.getLogger(__name__) @@ -164,6 +167,19 @@ def __init__(self, downloads_dir: Path, game: Game, *keywords): self.do_find(*self.keywords) self.stage() + def get_mods(self): + # Instance a Mod class for each mod folder in the mod directory. + mods = [] + mod_folders = [i for i in self.game.ammo_mods_dir.iterdir() if i.is_dir()] + for path in mod_folders: + mod = BethesdaMod( + location=self.game.ammo_mods_dir / path.name, + game_root=self.game.directory, + game_data=self.game.data, + ) + mods.append(mod) + return mods + def __str__(self) -> str: """ Output a string representing all downloads, mods and plugins. @@ -266,6 +282,18 @@ def save_order(self): for mod in self.mods: file.write(f"{'*' if mod.enabled else ''}{mod.name}\n") + def has_extra_folder(self, path) -> bool: + files = list(path.iterdir()) + return all( + [ + len(files) == 1, + files[0].is_dir(), + files[0].name.lower() != self.game.data.name.lower(), + files[0].name.lower() not in NO_EXTRACT_DIRS, + files[0].suffix.lower() not in [".esp", ".esl", ".esm"], + ] + ) + def set_mod_state(self, index: int, desired_state: bool): """ Activate or deactivate a mod. @@ -429,7 +457,7 @@ def do_find(self, *keyword: str) -> None: component.visible = False # Hack to filter by fomods - if kw.lower() == "fomods" and isinstance(component, Mod): + if kw.lower() == "fomods" and isinstance(component, BethesdaMod): if component.fomod: component.visible = True diff --git a/ammo/controller/fomod.py b/ammo/controller/fomod.py index a7ff51d..f49e334 100755 --- a/ammo/controller/fomod.py +++ b/ammo/controller/fomod.py @@ -11,7 +11,10 @@ from xml.etree import ElementTree from functools import reduce from ammo.ui import Controller -from ammo.component import Mod +from ammo.component import ( + Mod, + BethesdaMod, +) from ammo.lib import normalize @@ -55,8 +58,14 @@ class Page: class FomodController(Controller): - def __init__(self, mod: Mod): - self.mod: Mod = mod + def __init__(self, mod: Mod | BethesdaMod): + self.mod: Mod | BethesdaMod = mod + + # Clean up previous configuration, if it exists. + try: + shutil.rmtree(self.mod.location / "ammo_fomod") + except FileNotFoundError: + pass # Parse the fomod installer. try: @@ -375,11 +384,11 @@ def install_files(self, selected_nodes: list) -> None: Copy the chosen files 'selected_nodes' from given mod at 'index' to that mod's game files folder. """ - data = self.mod.location / "ammo_fomod" / self.mod.game_data.name + ammo_fomod = self.mod.location / self.mod.fomod_target # delete the old configuration if it exists. - shutil.rmtree(data, ignore_errors=True) - Path.mkdir(data, parents=True, exist_ok=True) + shutil.rmtree(ammo_fomod, ignore_errors=True) + Path.mkdir(ammo_fomod, parents=True, exist_ok=True) stage = {} for node in selected_nodes: @@ -405,13 +414,12 @@ def install_files(self, selected_nodes: list) -> None: full_destination = reduce( lambda path, name: path / name, node.get("destination").split("\\"), - data, + ammo_fomod, ) # TODO: this is broken :) # Normalize the capitalization of folder names - - full_destination = normalize(full_destination, data.parent) + full_destination = normalize(full_destination, ammo_fomod.parent) # Handle the mod's file conflicts that are caused by itself. # There's technically a priority clause in the fomod spec that diff --git a/ammo/controller/mod.py b/ammo/controller/mod.py index f3dd142..40647c9 100755 --- a/ammo/controller/mod.py +++ b/ammo/controller/mod.py @@ -24,7 +24,6 @@ ) from ammo.lib import ( normalize, - NO_EXTRACT_DIRS, ) from .tool import ToolController from .fomod import FomodController @@ -62,17 +61,7 @@ def __init__(self, downloads_dir: Path, game: Game, *keywords): logging.basicConfig(filename=self.game.ammo_log, level=logging.INFO) log.info("initializing") - # Instance a Mod class for each mod folder in the mod directory. - mods = [] - mod_folders = [i for i in self.game.ammo_mods_dir.iterdir() if i.is_dir()] - for path in mod_folders: - mod = Mod( - location=self.game.ammo_mods_dir / path.name, - game_root=self.game.directory, - game_data=self.game.data, - ) - mods.append(mod) - + mods = self.get_mods() # Read self.game.ammo_conf. If there's mods in it, put them in order. if self.game.ammo_conf.exists(): with open(self.game.ammo_conf, "r") as file: @@ -108,6 +97,18 @@ def __init__(self, downloads_dir: Path, game: Game, *keywords): self.do_find(*self.keywords) self.stage() + def get_mods(self): + # Instance a Mod class for each mod folder in the mod directory. + mods = [] + mod_folders = [i for i in self.game.ammo_mods_dir.iterdir() if i.is_dir()] + for path in mod_folders: + mod = Mod( + location=self.game.ammo_mods_dir / path.name, + game_root=self.game.directory, + ) + mods.append(mod) + return mods + def __str__(self) -> str: """ Output a string representing all downloads, mods. @@ -320,6 +321,15 @@ def clean_game_dir(self): self.remove_empty_dirs() + def has_extra_folder(self, path) -> bool: + files = list(path.iterdir()) + return all( + [ + len(files) == 1, + files[0].is_dir(), + ] + ) + def do_activate_mod(self, index: Union[int, str]) -> None: """ Enabled mods will be loaded by game. @@ -562,7 +572,7 @@ def do_configure(self, index: int) -> None: """ # Since there must be a hard refresh after the fomod wizard to load the mod's new # files, deactivate this mod and commit changes. This prevents a scenario where - # the user could re-configure a fomod (thereby changing mod.location/self.game.data.name), + # the user could re-configure a fomod (thereby changing mod.location/ammo_conf), # and quit ammo without running 'commit', which could leave broken symlinks in their # game.directory. @@ -582,12 +592,6 @@ def do_configure(self, index: int) -> None: self.do_commit() self.do_refresh() - # Clean up previous configuration, if it exists. - try: - shutil.rmtree(mod.location / "ammo_fomod" / self.game.data.name) - except FileNotFoundError: - pass - # We need to instantiate a FomodController and run it against the UI. # This will be a new instance of the UI. fomod_controller = FomodController(mod) @@ -718,7 +722,7 @@ def do_delete_mod(self, index: Union[int, str]) -> None: self.set_mod_state(self.mods.index(target_mod), False) self.mods.remove(target_mod) try: - log.info(f"Deleting Component: {target_mod.location}") + log.info(f"Deleting MOD: {target_mod.location}") shutil.rmtree(target_mod.location) except FileNotFoundError: pass @@ -791,18 +795,6 @@ def do_install(self, index: Union[int, str]) -> None: if index != "all": raise Warning(e) - def has_extra_folder(path) -> bool: - files = list(path.iterdir()) - return all( - [ - len(files) == 1, - files[0].is_dir(), - files[0].name.lower() != self.game.data.name.lower(), - files[0].name.lower() not in NO_EXTRACT_DIRS, - files[0].suffix.lower() not in [".esp", ".esl", ".esm"], - ] - ) - def install_download(index, download) -> None: extract_to = "".join( [ @@ -839,7 +831,7 @@ def install_download(index, download) -> None: os.system(f"7z x '{download.location}' -o'{extract_to}'") - if has_extra_folder(extract_to): + if self.has_extra_folder(extract_to): # It is reasonable to conclude an extra directory can be eliminated. # This is needed for mods like skse that have a version directory # between the mod's base folder and the self.game.data.name folder. @@ -852,7 +844,6 @@ def install_download(index, download) -> None: Mod( location=extract_to, game_root=self.game.directory, - game_data=self.game.data, ) ) diff --git a/test/bethesda/common.py b/test/bethesda/common.py index 8588152..eeaf0ba 100755 --- a/test/bethesda/common.py +++ b/test/bethesda/common.py @@ -8,7 +8,7 @@ BethesdaGame, ) from ammo.controller.fomod import FomodController -from ammo.component import Mod +from ammo.component import BethesdaMod # Create a configuration for the mock controller to use. @@ -88,7 +88,7 @@ def remove_empty_dirs(path): class FomodContextManager: - def __init__(self, mod: Mod): + def __init__(self, mod: BethesdaMod): self.mod = mod def __enter__(self): @@ -201,7 +201,7 @@ def fomod_selections_choose_files(mod_name, files, selections=[]): mod_index = [i.name for i in controller.mods].index(mod_name) mod = controller.mods[mod_index] try: - shutil.rmtree(mod.location / "ammo_fomod" / "Data") + shutil.rmtree(mod.location / mod.fomod_target) except FileNotFoundError: pass @@ -220,7 +220,7 @@ def fomod_selections_choose_files(mod_name, files, selections=[]): # Check that all the expected files exist. for file in files: - expected_file = mod.location / "ammo_fomod" / file + expected_file = mod.location / mod.fomod_target / file if not expected_file.exists(): # print the files that _do_ exist to show where things ended up for parent_dir, folders, actual_files in os.walk( @@ -233,10 +233,10 @@ def fomod_selections_choose_files(mod_name, files, selections=[]): raise FileNotFoundError(expected_file) # Check that no unexpected files exist. - for path, folders, filenames in os.walk(mod.location / "ammo_fomod" / "Data"): + for path, folders, filenames in os.walk(mod.location / mod.fomod_target): for file in filenames: exists = os.path.join(path, file) - local_exists = exists.split(str(mod.location / "ammo_fomod"))[ + local_exists = exists.split(str(mod.location / mod.fomod_target))[ -1 ].lstrip("/") assert local_exists in [ diff --git a/test/bethesda/test_compatibility_fomod.py b/test/bethesda/test_compatibility_fomod.py index 8aad3ff..0eed29c 100755 --- a/test/bethesda/test_compatibility_fomod.py +++ b/test/bethesda/test_compatibility_fomod.py @@ -15,8 +15,8 @@ def test_base_object_swapper(): XML into a full path is accurate. """ files = [ - Path("Data/SKSE/Plugins/po3_BaseObjectSwapper.dll"), - Path("Data/SKSE/Plugins/po3_BaseObjectSwapper.pdb"), + Path("SKSE/Plugins/po3_BaseObjectSwapper.dll"), + Path("SKSE/Plugins/po3_BaseObjectSwapper.pdb"), ] fomod_selections_choose_files( @@ -65,31 +65,29 @@ def test_realistic_ragdolls(): This verifies auto-selection for "selectExactlyOne" """ files = [ - Path("Data/realistic_ragdolls_Realistic.esp"), - Path("Data/meshes/actors/bear/character assets/skeleton.nif"), - Path("Data/meshes/actors/canine/character assets dog/skeleton.nif"), - Path("Data/meshes/actors/canine/character assets wolf/skeleton.nif"), - Path("Data/meshes/actors/cow/character assets/skeleton.nif"), - Path("Data/meshes/actors/deer/character assets/skeleton.nif"), - Path("Data/meshes/actors/draugr/character assets/skeleton.nif"), - Path("Data/meshes/actors/falmer/character assets/skeleton.nif"), - Path("Data/meshes/actors/frostbitespider/character assets/skeleton.nif"), - Path("Data/meshes/actors/giant/character assets/skeleton.nif"), - Path("Data/meshes/actors/goat/character assets/skeleton.nif"), - Path("Data/meshes/actors/hagraven/character assets/skeleton.nif"), - Path("Data/meshes/actors/mudcrab/character assets/skeleton.nif"), - Path("Data/meshes/actors/sabrecat/character assets/skeleton.nif"), - Path("Data/meshes/actors/troll/character assets/skeleton.nif"), - Path("Data/meshes/actors/werewolfbeast/character assets/skeleton.nif"), - Path("Data/meshes/actors/wolf/character assets/skeleton.nif"), + Path("realistic_ragdolls_Realistic.esp"), + Path("meshes/actors/bear/character assets/skeleton.nif"), + Path("meshes/actors/canine/character assets dog/skeleton.nif"), + Path("meshes/actors/canine/character assets wolf/skeleton.nif"), + Path("meshes/actors/cow/character assets/skeleton.nif"), + Path("meshes/actors/deer/character assets/skeleton.nif"), + Path("meshes/actors/draugr/character assets/skeleton.nif"), + Path("meshes/actors/falmer/character assets/skeleton.nif"), + Path("meshes/actors/frostbitespider/character assets/skeleton.nif"), + Path("meshes/actors/giant/character assets/skeleton.nif"), + Path("meshes/actors/goat/character assets/skeleton.nif"), + Path("meshes/actors/hagraven/character assets/skeleton.nif"), + Path("meshes/actors/mudcrab/character assets/skeleton.nif"), + Path("meshes/actors/sabrecat/character assets/skeleton.nif"), + Path("meshes/actors/troll/character assets/skeleton.nif"), + Path("meshes/actors/werewolfbeast/character assets/skeleton.nif"), + Path("meshes/actors/wolf/character assets/skeleton.nif"), + Path("meshes/actors/character/character assets female/skeleton_female.nif"), Path( - "Data/meshes/actors/character/character assets female/skeleton_female.nif" + "meshes/actors/character/character assets female/skeletonbeast_female.nif" ), - Path( - "Data/meshes/actors/character/character assets female/skeletonbeast_female.nif" - ), - Path("Data/meshes/actors/character/character assets/skeleton.nif"), - Path("Data/meshes/actors/character/character assets/skeletonbeast.nif"), + Path("meshes/actors/character/character assets/skeleton.nif"), + Path("meshes/actors/character/character assets/skeletonbeast.nif"), ] fomod_selections_choose_files( @@ -103,7 +101,7 @@ def test_realistic_ragdolls_no_ragdolls(): This verifies that selections in "selectExactlyOne" change flags only when they are supposed to. """ files = [ - Path("Data/realistic_ragdolls_Realistic.esp"), + Path("realistic_ragdolls_Realistic.esp"), ] fomod_selections_choose_files( @@ -159,9 +157,9 @@ def test_fomod_relighting_skyrim(): """ files = [ # requiredInstallFiles - Path("Data/meshes/Relight/LightOccluder.nif"), + Path("meshes/Relight/LightOccluder.nif"), # Default options: USSEP yes, both indoors and outdoors - Path("Data/RelightingSkyrim_SSE.esp"), + Path("RelightingSkyrim_SSE.esp"), ] fomod_selections_choose_files( @@ -170,7 +168,7 @@ def test_fomod_relighting_skyrim(): ) -def test_fomod_relighting_skryim_exteriors_only(): +def test_fomod_relighting_skyrim_exteriors_only(): """ Exteriors only does not rely on the USSEP flag, it is the same plugin regardless of whether the user selected the USSEP option. @@ -181,9 +179,9 @@ def test_fomod_relighting_skryim_exteriors_only(): """ files = [ # requiredInstallFiles - Path("Data/meshes/Relight/LightOccluder.nif"), + Path("meshes/Relight/LightOccluder.nif"), # exterior-only plugin - Path("Data/RelightingSkyrim_SSE_Exteriors.esp"), + Path("RelightingSkyrim_SSE_Exteriors.esp"), ] # With ussep @@ -227,9 +225,9 @@ def test_fomod_relighting_skyrim_interiors_only(): """ files = [ # requiredInstallFiles - Path("Data/meshes/Relight/LightOccluder.nif"), + Path("meshes/Relight/LightOccluder.nif"), # conditionalFileInstalls - Path("Data/RelightingSkyrim_SSE_Interiors.esp"), + Path("RelightingSkyrim_SSE_Interiors.esp"), ] # With ussep @@ -248,7 +246,7 @@ def test_fomod_relighting_skyrim_interiors_only(): ], ) - files[-1] = Path("Data/RelightingSkyrim_SSE_Interiors_nonUSSEP.esp") + files[-1] = Path("RelightingSkyrim_SSE_Interiors_nonUSSEP.esp") # Without ussep fomod_selections_choose_files( "mock_relighting_skyrim", diff --git a/test/bethesda/test_fomod_controller_instance.py b/test/bethesda/test_fomod_controller_instance.py index 765e16e..7c05975 100755 --- a/test/bethesda/test_fomod_controller_instance.py +++ b/test/bethesda/test_fomod_controller_instance.py @@ -24,7 +24,7 @@ def test_missing_data_fomod(): """ files = [ # Default options: USSEP yes, both indoors and outdoors - Path("Data/test.esp"), + Path("test.esp"), ] fomod_selections_choose_files(