Skip to content

Commit

Permalink
Refactor recoverable exception handling
Browse files Browse the repository at this point in the history
Previously, I was explicitly raising IndexError and TypeError for
recoverable errors (like when a user enters a non-existing index as an
argument, or attempt to activate a download, for example), and catching
them in the UI class. This is bad for multiple reasons, but the main one
is that it can potentially mask logical errors. I would prefer a logical
error crash so I can find and fix the bug.

This commit demotes all recoverable errors to the built-in Warning(),
and makes the UI class only except (accept?) Warnings.

This also removes the "vanilla" command, in favor of:
```
deactivate mod all
commit
```
Finally, this change makes it so the UI class doesn't expect a return
status from functions in the controller. All controller methods are
assumed to be successful if they don't raise a Warning (recoverable) or
an Exception of any kind (now correctly crashes).

This is the first step towards moving business logic out of the UI
class, which will allow it to be generalized, then recycled for the
upcoming fomod controller.
  • Loading branch information
cyberrumor committed Oct 28, 2023
1 parent eb0510e commit 2d5369a
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 176 deletions.
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,45 @@ help Show this menu.
install <index> Extract and manage an archive from ~/Downloads.
move mod|plugin <from_index> <to_index> Larger numbers win file conflicts.
refresh Abandon pending changes.
vanilla Disable all managed components and clean up.
```
Note that the `de/activate mod|plugin` command now supports `all` in place of `<index>`.

# Tips and Tricks

Note that the `de/activate mod|plugin` command supports `all` in place of `<index>`.
This will activate or deactivate all mods or plugins that are visible. Combine this
with the `find` command to quickly organize groups of components with related names.
You can leverage this to automatically sort your plugins to the same order as your
mod list:
```
deactivate mod all
# sort your mods with the move command
activate mod all
activate plugin all
commit
```

The `find` command accepts a special `fomods` argument that will filter by fomods.

The `find` command allows you to locate plugins owned by a particular mod, or mods
that have a particular plugin. It also lets you find mods / plugins / downloads via
keyword. This is an additive filter, so more words equals more matches.

You can easily return to vanilla like this:
```
deactivate mod all
commit
```

If you don't know how many components are in your list and you want to move a
component to the bottom, you can throw in an arbitrarily large number as the
`<to index>` for the `move` command, and it will be moved to the last position.
This only works for the `move` command.

If you have several downloads and you want to install all of them at once, simply
`install all`.

Combining `find` filters with `all` is a great way to quickly manage groups of
related components, as the `all` keyword only operates on visible components.

# Technical Details
- AMMO works via creating symlinks in your game directory pointing to your mod files.
Expand Down
163 changes: 78 additions & 85 deletions ammo/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def __init__(self, downloads_dir: Path, game: Game, *args):
self.changes = False
self.find(*self.keywords)

def _save_order(self) -> bool:
def _save_order(self):
"""
Writes ammo.conf and Plugins.txt.
"""
Expand All @@ -163,7 +163,6 @@ def _save_order(self) -> bool:
with open(self.game.ammo_conf, "w") as file:
for mod in self.mods:
file.write(f"{'*' if mod.enabled else ''}{mod.name}\n")
return True

def _get_validated_components(self, component_type: str) -> list:
"""
Expand All @@ -173,14 +172,12 @@ def _get_validated_components(self, component_type: str) -> list:
Otherwise, return False.
"""
if component_type not in ["plugin", "mod"]:
raise TypeError
raise Warning("Only plugins and mods can be de/activated.")

components = self.plugins if component_type == "plugin" else self.mods
return components

def _set_component_state(
self, component_type: str, mod_index: int, state: bool
) -> bool:
def _set_component_state(self, component_type: str, mod_index: int, state: bool):
"""
Activate or deactivate a component.
If a mod with plugins was deactivated, remove those plugins from self.plugins
Expand All @@ -199,9 +196,7 @@ def _set_component_state(
and state
and not component.has_data_dir
):
print("Fomods must be configured before they can be enabled.")
print(f"Please run 'configure {mod_index}', refresh, and try again.")
return False
raise Warning("Fomods must be configured before they can be enabled.")

component.enabled = state
if component.enabled:
Expand Down Expand Up @@ -231,7 +226,6 @@ def _set_component_state(
component.enabled = state

self.changes = starting_state != component.enabled
return True

def _normalize(self, destination: Path, dest_prefix: Path) -> Path:
"""
Expand Down Expand Up @@ -295,7 +289,7 @@ def _stage(self) -> dict:

return result

def _clean_data_dir(self) -> bool:
def _clean_data_dir(self):
"""
Removes all links and deletes empty folders.
"""
Expand All @@ -321,7 +315,6 @@ def remove_empty_dirs(path: Path):
pass

remove_empty_dirs(self.game.directory)
return True

def _fomod_get_flags(self, steps) -> dict:
"""
Expand Down Expand Up @@ -499,6 +492,9 @@ def _fomod_get_nodes(self, xml_root_node, steps: dict, flags: dict) -> list:
else:
selected_nodes.append(file)

assert (
len(selected_nodes) > 0
), "The selected options failed to map to installable components."
return selected_nodes

def _fomod_flags_match(self, flags: dict, expected_flags: dict) -> bool:
Expand Down Expand Up @@ -622,12 +618,13 @@ def _fomod_install_files(self, index, selected_nodes: list):

mod.has_data_dir = True

def configure(self, index) -> bool:
def configure(self, index):
"""
Configure a fomod.
"""
# This has to run a hard refresh for now, so warn if there are uncommitted changes
assert self.changes is False
if self.changes is True:
raise Warning("You must `commit` changes before configuring a fomod.")

# 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
Expand All @@ -637,7 +634,7 @@ def configure(self, index) -> bool:

mod = self.mods[int(index)]
if not mod.fomod:
raise TypeError
raise Warning("Only fomods can be configured.")

self.deactivate("mod", index)
self.commit()
Expand Down Expand Up @@ -713,7 +710,7 @@ def configure(self, index) -> bool:

if "exit" == selection:
self.refresh()
return True
return

if "n" == selection:
page_index += 1
Expand Down Expand Up @@ -755,9 +752,7 @@ def configure(self, index) -> bool:
# Whenever a plugin is unselected, re-assess all flags.
self._fomod_select(page, selection)

if not (install_nodes := self._fomod_get_nodes(xml_root_node, steps, flags)):
print("The configured options failed to map to installable components!")
return False
install_nodes = self._fomod_get_nodes(xml_root_node, steps, flags)

# Let the controller stage the chosen files and copy them to the mod's local Data dir.
self._fomod_install_files(index, install_nodes)
Expand All @@ -766,79 +761,95 @@ def configure(self, index) -> bool:
# resetting the controller and preventing configuration when there are unsaved changes
# will no longer be required.
self.refresh()
return True

def activate(self, mod_or_plugin: Mod | Plugin, index) -> bool:
def activate(self, mod_or_plugin: Mod | Plugin, index):
"""
Enabled components will be loaded by game.
"""
if index == "all" and mod_or_plugin in ["mod", "plugin"]:
if mod_or_plugin not in ["mod", "plugin"]:
raise Warning("You can only activate mods or plugins")
if index == "all":
for i in range(len(self.__dict__[f"{mod_or_plugin}s"])):
if self.__dict__[f"{mod_or_plugin}s"][i].visible:
if self._set_component_state(mod_or_plugin, i, True) is False:
return False
return True

return self._set_component_state(mod_or_plugin, index, True)
self._set_component_state(mod_or_plugin, i, True)
else:
try:
self._set_component_state(mod_or_plugin, index, True)
except IndexError as e:
# Demote IndexErrors
raise Warning(e)

def deactivate(self, mod_or_plugin: Mod | Plugin, index) -> bool:
def deactivate(self, mod_or_plugin: Mod | Plugin, index):
"""
Disabled components will not be loaded by game.
"""
if index == "all" and mod_or_plugin in ["mod", "plugin"]:
if mod_or_plugin not in ["mod", "plugin"]:
raise Warning("You can only deactivate mods or plugins")
if index == "all":
for i in range(len(self.__dict__[f"{mod_or_plugin}s"])):
if self.__dict__[f"{mod_or_plugin}s"][i].visible:
if self._set_component_state(mod_or_plugin, i, False) is False:
return False
return True

return self._set_component_state(mod_or_plugin, index, False)
self._set_component_state(mod_or_plugin, i, False)
else:
try:
self._set_component_state(mod_or_plugin, index, False)
except IndexError as e:
# Demote IndexErrors
raise Warning(e)

def delete(self, mod_or_download: Mod | Download, index) -> bool:
def delete(self, mod_or_download: Mod | Download, index):
"""
Removes specified file from the filesystem.
"""
assert self.changes is False
if self.changes is True:
raise Warning("You must `commit` changes before deleting a mod.")

if mod_or_download not in ["download", "mod"]:
raise TypeError
raise Warning("Only downloads and mods may be deleted.")

if mod_or_download == "mod":
if index == "all":
visible_mods = [i for i in self.mods if i.visible]
for mod in visible_mods:
if not self.deactivate("mod", self.mods.index(mod)):
# Don't get rid of stuff if we can't hide plugins
return False
self.deactivate("mod", self.mods.index(mod))
for mod in visible_mods:
self.mods.pop(self.mods.index(mod))
shutil.rmtree(mod.location)
else:
if not self.deactivate("mod", index):
# validation error
return False
try:
self.deactivate("mod", index)
except IndexError as e:
# Demote IndexErrors
raise Warning(e)

# Remove the mod from the controller then delete it.
mod = self.mods.pop(int(index))
shutil.rmtree(mod.location)
self.commit()
else:
return
elif mod_or_download == "download":
if index == "all":
visible_downloads = [i for i in self.downloads if i.visible]
for download in visible_downloads:
os.remove(self.downloads[self.downloads.index(download)].location)
self.downloads.pop(self.downloads.index(download))
else:
index = int(index)
os.remove(self.downloads[index].location)
self.downloads.pop(index)
return True
return
index = int(index)
try:
download = self.downloads.pop(index)
except IndexError as e:
# Demote IndexErrors
raise Warning(e)
os.remove(download.location)
return

def install(self, index) -> bool:
raise Warning(f"Expected 'mod' or 'download' but got {mod_or_download}")

def install(self, index):
"""
Extract and manage an archive from ~/Downloads.
"""
assert self.changes is False
if self.changes is True:
raise Warning("You must `commit` changes before installing a mod.")

def install_download(download):
if not download.sane:
Expand Down Expand Up @@ -868,7 +879,9 @@ def install_download(download):

extract_to = os.path.join(self.game.ammo_mods_dir, output_folder)
if os.path.exists(extract_to):
raise FileExistsError
raise Warning(
"This mod appears to already be installed. Please delete it before reinstalling."
)

extracted_files = []
os.system(f"7z x '{download.location}' -o'{extract_to}'")
Expand Down Expand Up @@ -908,13 +921,17 @@ def install_download(download):
install_download(download)
else:
index = int(index)
download = self.downloads[index]
try:
download = self.downloads[index]
except IndexError as e:
# Demote IndexErrors
raise Warning(e)

install_download(download)

self.refresh()
return True

def move(self, mod_or_plugin: Mod | Plugin, from_index, to_index) -> bool:
def move(self, mod_or_plugin: Mod | Plugin, from_index, to_index):
"""
Larger numbers win file conflicts.
"""
Expand All @@ -924,37 +941,18 @@ def move(self, mod_or_plugin: Mod | Plugin, from_index, to_index) -> bool:
old_ind = int(from_index)
new_ind = int(to_index)
if old_ind == new_ind:
return True
# no op
return
if new_ind > len(components) - 1:
# Auto correct astronomical <to index> to max.
new_ind = len(components) - 1
if old_ind > len(components) - 1:
raise IndexError
raise Warning("Index out of range.")
component = components.pop(old_ind)
components.insert(new_ind, component)
self.changes = True
return True

def vanilla(self) -> bool:
"""
Disable all managed components and clean up.
"""
print(
"This will disable all mods and plugins, and remove all symlinks, \
hardlinks, and empty folders from the game.directory."
)
print("ammo will remember th mod load order but not the plugin load order.")
print("These changes will take place immediately.")
if input("continue? [y/n]").lower() != "y":
print("Not cleaned.")
return False

for mod in range(len(self.mods)):
self._set_component_state("mod", mod, False)
self._save_order()
self._clean_data_dir()
return True

def commit(self) -> bool:
def commit(self):
"""
Apply pending changes.
"""
Expand All @@ -980,15 +978,12 @@ def commit(self) -> bool:
for skipped_file in skipped_files:
print(skipped_file)
self.changes = False
# Always return False so status messages persist.
return False

def refresh(self) -> bool:
def refresh(self):
"""
Abandon pending changes.
"""
self.__init__(self.downloads_dir, self.game, *self.keywords)
return True

def find(self, *args):
"""
Expand Down Expand Up @@ -1027,5 +1022,3 @@ def find(self, *args):
for mod in self.mods:
if plugin.name in mod.plugins:
mod.visible = True

return True
Loading

0 comments on commit 2d5369a

Please sign in to comment.