From 4c1d530613c9e59e37cbf295ced0a5012e40317b Mon Sep 17 00:00:00 2001 From: cyberrumor Date: Mon, 9 Oct 2023 23:26:36 -0700 Subject: [PATCH] USE HARDLINKS BY DEFAULT You can still use symlinks via launching ammo with -s. The reason for this change is to improve compatibility with external tools like dyndolod, which seem to have trouble with symlinks. --- README.md | 6 ++++-- ammo/ammo.py | 12 +++++++++++- ammo/controller.py | 9 ++++++--- test/common.py | 13 +++++++------ test/test_conflict_resolution.py | 12 ++++++++---- test/test_controller_instance.py | 11 +++++++---- test/test_embers_xd.py | 6 ++++-- test/test_realistic_ragdolls.py | 1 - test/test_script_extender.py | 6 ++++-- test/test_skyui.py | 6 ++++-- 10 files changed, 55 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 130df8f..a302c03 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,11 @@ vanilla Disable all managed components and ``` # Technical Details -- AMMO works via creating symlinks in your game directory pointing to your mod files. +- AMMO works via creating hardlinks (or symlinks with `ammo -s`) in your game directory + pointing to your mod files. - When you install an archive, the archive may be renamed to remove special characters. -- This will remove symlinks and empty directories from your game dir, and reinstall them whenever you commit. +- This will remove symlinks, hardlinks, and empty directories from your game dir, + and reinstall them whenever you commit. # License GNU General Public License v2, with the exception of some of the mock mods used for testing, diff --git a/ammo/ammo.py b/ammo/ammo.py index e1d3e2e..c99b4b6 100755 --- a/ammo/ammo.py +++ b/ammo/ammo.py @@ -19,6 +19,16 @@ def main(): + + args = sys.argv + + if "-h" in args or "--help" in args: + print("Usage: ammo [options]") + print("-s --symlink Use symlinks instead of hardlinks") + sys.exit(0) + + use_symlinks = "-s" in sys.argv or "--symlink" in sys.argv + # game selection games = [game.name for game in (STEAM / "common").iterdir() if game.name in IDS] if not games: @@ -83,7 +93,7 @@ def main(): ) # Create an instance of the controller. - controller = Controller(DOWNLOADS, game) + controller = Controller(DOWNLOADS, game, use_symlinks) # Run the UI against the controller. ui = UI(controller) diff --git a/ammo/controller.py b/ammo/controller.py index f049f19..9e84795 100755 --- a/ammo/controller.py +++ b/ammo/controller.py @@ -14,7 +14,8 @@ class Controller: - def __init__(self, downloads_dir: Path, game: Game): + def __init__(self, downloads_dir: Path, game: Game, use_symlinks=False): + self.use_symlinks = use_symlinks self.downloads_dir: Path = downloads_dir self.game: Game = game self.changes: bool = False @@ -919,8 +920,10 @@ def commit(self) -> bool: Path.mkdir(dest.parent, parents=True, exist_ok=True) (name, src) = source try: - dest.symlink_to(src) - # dest.hardlink_to(src) + if self.use_symlinks: + dest.symlink_to(src) + else: + dest.hardlink_to(src) except FileExistsError: skipped_files.append( f"{name} skipped overwriting an unmanaged file: \ diff --git a/test/common.py b/test/common.py index 10f415d..4ae0cdf 100755 --- a/test/common.py +++ b/test/common.py @@ -33,7 +33,8 @@ class AmmoController: Removes those folders on exit or error. """ - def __init__(self): + def __init__(self, use_symlinks=False): + self.use_symlinks = use_symlinks self.game = GAME script_path = Path(__file__) self.downloads_dir = script_path.parent / "Downloads" @@ -43,7 +44,7 @@ def __enter__(self): Return an instance of ammo's controller for tests to interact with. """ - return Controller(self.downloads_dir, self.game) + return Controller(self.downloads_dir, self.game, self.use_symlinks) def __exit__(self, *args, **kwargs): """ @@ -109,12 +110,12 @@ def mod_extracts_files(mod_name, files): raise FileNotFoundError(expected_file) -def mod_installs_files(mod_name, files): +def mod_installs_files(mod_name, files, use_symlinks=False): """ Expects the name of a file in Downloads, and a list of file paths that should exist after installation and commit, relative to the game's directory. """ - with AmmoController() as controller: + with AmmoController(use_symlinks) as controller: # install the mod mod_index_download = [i.name for i in controller.downloads].index( mod_name + ".7z" @@ -155,7 +156,7 @@ def mod_installs_files(mod_name, files): ), f"Detected lonely hard link: {expected_file}" -def fomod_selections_choose_files(mod_name, files, selections=[]): +def fomod_selections_choose_files(mod_name, files, selections=[], use_symlinks=False): """ Configure a fomod with flags, using default flags if unspecified. @@ -164,7 +165,7 @@ def fomod_selections_choose_files(mod_name, files, selections=[]): selections is a list of {"page": , "option": } """ - with AmmoController() as controller: + with AmmoController(use_symlinks) as controller: mod_index_download = [i.name for i in controller.downloads].index( mod_name + ".7z" ) diff --git a/test/test_conflict_resolution.py b/test/test_conflict_resolution.py index 77722f3..a61fa4d 100755 --- a/test/test_conflict_resolution.py +++ b/test/test_conflict_resolution.py @@ -3,6 +3,8 @@ from pathlib import Path from common import AmmoController +import pytest + MOD_1 = "conflict_1" MOD_2 = "conflict_2" @@ -35,14 +37,15 @@ def test_duplicate_plugin(): assert len(controller.plugins) == 1 -def test_conflict_resolution(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_conflict_resolution(use_symlinks): """ Install two mods with the same files. Verify the symlinks point back to the mod last in the load order. Conflicts for all files and plugins are won by a single mod. """ - with AmmoController() as controller: + with AmmoController(use_symlinks) as controller: # Install both mods for mod in [MOD_1, MOD_2]: mod_index_download = [i.name for i in controller.downloads].index( @@ -97,14 +100,15 @@ def check_links(expected_game_file, expected_mod_file): check_links(expected_game_file, expected_mod_file) -def test_conflicting_plugins_disable(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_conflicting_plugins_disable(use_symlinks): """ Install two mods with the same files. Disable the one that is winning the conflict for the plugin. Test that the plugin isn't removed from the controller's plugins. """ - with AmmoController() as controller: + with AmmoController(use_symlinks) as controller: # Install both mods for mod in [MOD_1, MOD_2]: mod_index_download = [i.name for i in controller.downloads].index( diff --git a/test/test_controller_instance.py b/test/test_controller_instance.py index c67791f..6b71a4a 100755 --- a/test/test_controller_instance.py +++ b/test/test_controller_instance.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 import os from common import AmmoController, install_everything +import pytest -def test_controller_first_launch(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_controller_first_launch(use_symlinks): """ Sanity check the ammo_controller fixture, that it creates the game directory and properly removes it. """ - with AmmoController() as controller: + with AmmoController(use_symlinks) as controller: game_dir = controller.game.directory assert os.path.exists( game_dir @@ -47,13 +49,14 @@ def test_controller_first_launch(): ), f"ammo_dir {conf_dir} existed after the context manager closed." -def test_controller_subsequent_launch(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_controller_subsequent_launch(use_symlinks): """ Ensure ammo behaves correctly when launched against a game that already has mods installed, Plugins.txt populated with a non-default order, etc. """ - with AmmoController() as first_launch: + with AmmoController(use_symlinks) as first_launch: install_everything(first_launch) # change some config to ensure it's not just alphabetic diff --git a/test/test_embers_xd.py b/test/test_embers_xd.py index 9170bd0..2c8d916 100755 --- a/test/test_embers_xd.py +++ b/test/test_embers_xd.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 from pathlib import Path from common import mod_installs_files +import pytest -def test_fomod_embers_xd(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_fomod_embers_xd(use_symlinks): """ In the past, there were some issues with Embers XD plugin becoming visible upon activate, but not persisting through a refresh. @@ -18,4 +20,4 @@ def test_fomod_embers_xd(): Path("Data/Embers XD - Fire Magick Add-On.esp"), ] - mod_installs_files("mock_embers_xd", files) + mod_installs_files("mock_embers_xd", files, use_symlinks) diff --git a/test/test_realistic_ragdolls.py b/test/test_realistic_ragdolls.py index f8ba972..338ef42 100755 --- a/test/test_realistic_ragdolls.py +++ b/test/test_realistic_ragdolls.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 from pathlib import Path from common import ( - AmmoController, fomod_selections_choose_files, ) diff --git a/test/test_script_extender.py b/test/test_script_extender.py index dfdacd4..6b66344 100755 --- a/test/test_script_extender.py +++ b/test/test_script_extender.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from pathlib import Path from common import mod_extracts_files, mod_installs_files +import pytest MOD = "mock_script_extender" FILES = [ @@ -21,9 +22,10 @@ def test_install_script_extender(): mod_extracts_files(MOD, FILES) -def test_activate_script_extender(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_activate_script_extender(use_symlinks): """ Tests that activating a script extender causes files to exist in expected locations. """ - mod_installs_files(MOD, FILES) + mod_installs_files(MOD, FILES, use_symlinks) diff --git a/test/test_skyui.py b/test/test_skyui.py index fc5ae3a..6bf33b0 100755 --- a/test/test_skyui.py +++ b/test/test_skyui.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from pathlib import Path from common import mod_extracts_files, mod_installs_files +import pytest MOD = "mock_skyui" EXTRACT_FILES = [ @@ -20,11 +21,12 @@ def test_install_fake_fomod(): mod_extracts_files(MOD, EXTRACT_FILES) -def test_activate_fake_fomod(): +@pytest.mark.parametrize("use_symlinks", [True, False]) +def test_activate_fake_fomod(use_symlinks): """ Tests that activating a mod that has a fomod dir but no ModuleConfig.txt installs symlinks to the expected locations. Notably, anything inside of the fomod dir is undesired. """ - mod_installs_files(MOD, INSTALL_FILES) + mod_installs_files(MOD, INSTALL_FILES, use_symlinks)