Add Factorio/mod_updater.py
From Github: https://github.com/pdemonaco/factorio-mod-updater Check there for updates
This commit is contained in:
		
							parent
							
								
									76f4f05807
								
							
						
					
					
						commit
						d7416f87d3
					
				
					 1 changed files with 733 additions and 0 deletions
				
			
		
							
								
								
									
										733
									
								
								Factorio/mod_updater.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										733
									
								
								Factorio/mod_updater.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,733 @@
 | 
			
		|||
#!/usr/bin/python3
 | 
			
		||||
"""
 | 
			
		||||
This module provides a simple method to manage updating and installing mods
 | 
			
		||||
on a given factorio server.
 | 
			
		||||
 | 
			
		||||
It is currently not intended to be imported and instead should be executed
 | 
			
		||||
directly as a python script.
 | 
			
		||||
"""
 | 
			
		||||
import argparse
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from enum import Enum, auto
 | 
			
		||||
import glob
 | 
			
		||||
import hashlib
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
# External URL processing library
 | 
			
		||||
# http://docs.python-requests.org/en/master/user/quickstart/
 | 
			
		||||
import requests
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _validate_hash(checksum: str, target: str, bsize: int = 65536) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    Checks to see if the file specified by target matches the provided sha1
 | 
			
		||||
    checksum.
 | 
			
		||||
 | 
			
		||||
    Keyword Arguments:
 | 
			
		||||
    checksum -- sha1 digest to be matched
 | 
			
		||||
    target   -- path to the file which must be validated
 | 
			
		||||
    """
 | 
			
		||||
    hasher = hashlib.sha1()
 | 
			
		||||
 | 
			
		||||
    with open(target, "rb") as target_fp:
 | 
			
		||||
        block = target_fp.read(bsize)
 | 
			
		||||
        while len(block) > 0:
 | 
			
		||||
            hasher.update(block)
 | 
			
		||||
            block = target_fp.read(bsize)
 | 
			
		||||
 | 
			
		||||
    return hasher.hexdigest() == checksum
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _version_match(installed: str, mod: str):
 | 
			
		||||
    """Checks if factorio versions are compatible."""
 | 
			
		||||
    if installed.startswith("1.") and mod == "0.18":
 | 
			
		||||
        return True
 | 
			
		||||
    return installed == mod
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ModUpdater:
 | 
			
		||||
    """
 | 
			
		||||
    Internal class managing the current version and state of the mods on this
 | 
			
		||||
    server.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    MOD_VERSION_PATTERN = r"\d+[.]\d+[.]\d+"
 | 
			
		||||
    MOD_FILE_PATTERN = "^(.*)_({version})[.]zip$".format(version=MOD_VERSION_PATTERN)
 | 
			
		||||
 | 
			
		||||
    class Mode(Enum):
 | 
			
		||||
        """Possible execution modes"""
 | 
			
		||||
 | 
			
		||||
        LIST = auto()
 | 
			
		||||
        UPDATE = auto()
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        settings_path: str,
 | 
			
		||||
        data_path: str,
 | 
			
		||||
        mod_path: str,
 | 
			
		||||
        fact_path: str,
 | 
			
		||||
        creds: hash,
 | 
			
		||||
        title_mode: bool,
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        Initialize the updater class with all mandatory and optional arguments.
 | 
			
		||||
 | 
			
		||||
        Keyword arguments:
 | 
			
		||||
        settings_path -- absolute path to the server-settings.json file
 | 
			
		||||
        mod_path      -- absolute path to the factorio mod directory
 | 
			
		||||
        fact_ver      -- local factorio version
 | 
			
		||||
        """
 | 
			
		||||
        self.mod_server_url = "https://mods.factorio.com"
 | 
			
		||||
        self.mod_path = mod_path
 | 
			
		||||
        self.timestamp = datetime.utcnow()
 | 
			
		||||
        self.title_mode = title_mode
 | 
			
		||||
 | 
			
		||||
        # Get the credentials to download mods
 | 
			
		||||
        if settings_path is not None:
 | 
			
		||||
            self.settings = self._parse_settings(settings_path)
 | 
			
		||||
        else:
 | 
			
		||||
            self.settings = {}
 | 
			
		||||
        if data_path is not None:
 | 
			
		||||
            self.data = self._parse_settings(data_path)
 | 
			
		||||
        else:
 | 
			
		||||
            self.data = {}
 | 
			
		||||
 | 
			
		||||
        # Parse username and token
 | 
			
		||||
        if "username" in creds and creds["username"] is not None:
 | 
			
		||||
            self.username = creds["username"]
 | 
			
		||||
        elif "username" in self.settings:
 | 
			
		||||
            self.username = self.settings["username"]
 | 
			
		||||
        elif "service-username" in self.data:
 | 
			
		||||
            self.username = self.data["service-username"]
 | 
			
		||||
        else:
 | 
			
		||||
            self.token = None
 | 
			
		||||
 | 
			
		||||
        if "token" in creds and creds["token"] is not None:
 | 
			
		||||
            self.token = creds["token"]
 | 
			
		||||
        elif "token" in self.settings:
 | 
			
		||||
            self.token = self.settings["token"]
 | 
			
		||||
        elif "service-token" in self.data:
 | 
			
		||||
            self.token = self.data["service-token"]
 | 
			
		||||
        else:
 | 
			
		||||
            self.token = None
 | 
			
		||||
 | 
			
		||||
        # Ensure username and token were specified
 | 
			
		||||
        if self.username is None or self.username == "":
 | 
			
		||||
            errmsg = (
 | 
			
		||||
                "error: username not specified in "
 | 
			
		||||
                + "server-settings.json, player-data.json, or cli!"
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        if self.token is None or self.token == "":
 | 
			
		||||
            errmsg = (
 | 
			
		||||
                "error: token not specified in "
 | 
			
		||||
                + "server-settings.json, player-data.json, or cli!"
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        # Begin processing
 | 
			
		||||
        self._determine_version(fact_path)
 | 
			
		||||
        self._parse_mod_list()
 | 
			
		||||
        self._retrieve_metadata()
 | 
			
		||||
        self._determine_max_name_lengths()
 | 
			
		||||
        if self.title_mode:
 | 
			
		||||
            self.mods = OrderedDict(
 | 
			
		||||
                sorted(self.mods.items(), key=lambda mod: mod[1]["title"])
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            self.mods = OrderedDict(sorted(self.mods.items()))
 | 
			
		||||
 | 
			
		||||
    def _determine_version(self, fact_path: str):
 | 
			
		||||
        """Determine the local factorio version"""
 | 
			
		||||
        if not os.path.exists(fact_path):
 | 
			
		||||
            errmsg = "error: factorio binary '{fpath_path}' does not exist!"
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            output = subprocess.check_output(
 | 
			
		||||
                [fact_path, "--version"], universal_newlines=True
 | 
			
		||||
            )
 | 
			
		||||
            ver_re = re.compile(r"Version: (\d+)[.](\d+)[.](\d+) .*\n", re.RegexFlag.M)
 | 
			
		||||
            match = ver_re.match(output)
 | 
			
		||||
            if match:
 | 
			
		||||
                version = {}
 | 
			
		||||
                version["major"] = match.group(1)
 | 
			
		||||
                version["minor"] = match.group(2)
 | 
			
		||||
                version["patch"] = match.group(3)
 | 
			
		||||
                version["release"] = "{}.{}".format(version["major"], version["minor"])
 | 
			
		||||
                self.fact_version = version
 | 
			
		||||
            else:
 | 
			
		||||
                errmsg = "Unable to parse version from:\n{output}".format(output=output)
 | 
			
		||||
                print(errmsg, file=sys.stderr)
 | 
			
		||||
                sys.exit("1")
 | 
			
		||||
 | 
			
		||||
        except subprocess.CalledProcessError as error:
 | 
			
		||||
            errmsg = ("error: failed to run  '{fpath} --version': " "{errstr}").format(
 | 
			
		||||
                fpath=fact_path, errstr=error.stderr
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        print(
 | 
			
		||||
            "Factorio Release: {release}\n".format(release=self.fact_version["release"])
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _parse_settings(config_path: str):
 | 
			
		||||
        """Process the specified server-settings.json or player-data.json file."""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(config_path, "r") as config_fp:
 | 
			
		||||
                return json.load(config_fp)
 | 
			
		||||
        except IOError as error:
 | 
			
		||||
            errmsg = ("error: failed to open file '{fname}': " "{errstr}").format(
 | 
			
		||||
                fname=config_path, errstr=error.strerror
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
        except json.JSONDecodeError as error:
 | 
			
		||||
            errmsg = ("error: failed to parse json file '{fname}': " "{errstr}").format(
 | 
			
		||||
                fname=config_path, errstr=error.msg
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    def _retrieve_metadata(self):
 | 
			
		||||
        """
 | 
			
		||||
        Pull the latest metadata for each mod from the factorio server
 | 
			
		||||
        See https://wiki.factorio.com/Mod_portal_API for details
 | 
			
		||||
        """
 | 
			
		||||
        print("Retrieving metadata", end="")
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            self._retrieve_mod_metadata(mod)
 | 
			
		||||
            print(".", end="", flush=True)
 | 
			
		||||
        print("complete!\n")
 | 
			
		||||
 | 
			
		||||
        # Add missing dependencies to the overall list
 | 
			
		||||
        while True:
 | 
			
		||||
            missing_mods = []
 | 
			
		||||
            for mod, data in self.mods.items():
 | 
			
		||||
                if "missing_deps" in data:
 | 
			
		||||
                    missing_mods.extend(data["missing_deps"])
 | 
			
		||||
 | 
			
		||||
            unique_missing = set(missing_mods)
 | 
			
		||||
            for mod in self.mods.keys():
 | 
			
		||||
                if mod in unique_missing:
 | 
			
		||||
                    unique_missing.remove(mod)
 | 
			
		||||
            if len(unique_missing) == 0:
 | 
			
		||||
                break
 | 
			
		||||
            for mod in unique_missing:
 | 
			
		||||
                entry = {}
 | 
			
		||||
                entry["enabled"] = True
 | 
			
		||||
                entry["installed"] = False
 | 
			
		||||
                self.mods[mod] = entry
 | 
			
		||||
                self._retrieve_mod_metadata(mod)
 | 
			
		||||
                print("Info: adding missing dependency {dep}".format(dep=mod))
 | 
			
		||||
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            if "metadata" not in data:
 | 
			
		||||
                warnmsg = (
 | 
			
		||||
                    "Warning: Unable to retrieve metadata for"
 | 
			
		||||
                    " {mod}, skipped!".format(mod=mod)
 | 
			
		||||
                )
 | 
			
		||||
                print(warnmsg)
 | 
			
		||||
 | 
			
		||||
    def _retrieve_mod_metadata(self, mod: str):
 | 
			
		||||
        """
 | 
			
		||||
        Attempts to retrieve the metadata for the target mod. If found, the
 | 
			
		||||
        data object is updated with the 'metadata' key and the 'latest' keys.
 | 
			
		||||
        """
 | 
			
		||||
        data = self.mods[mod]
 | 
			
		||||
        mod_url = self.mod_server_url + "/api/mods/" + mod + "/full"
 | 
			
		||||
        with requests.get(mod_url) as req:
 | 
			
		||||
            if req.status_code == 200:
 | 
			
		||||
                data["metadata"] = req.json()
 | 
			
		||||
 | 
			
		||||
        if "metadata" in data:
 | 
			
		||||
            # Find the latest release for this version of Factorio
 | 
			
		||||
            matching_releases = []
 | 
			
		||||
            for rel in data["metadata"]["releases"]:
 | 
			
		||||
                rel_ver = rel["info_json"]["factorio_version"]
 | 
			
		||||
                if _version_match(installed=self.fact_version["release"], mod=rel_ver):
 | 
			
		||||
                    matching_releases.append(rel)
 | 
			
		||||
 | 
			
		||||
            if len(matching_releases) > 0:
 | 
			
		||||
                data["latest"] = matching_releases[-1]
 | 
			
		||||
 | 
			
		||||
            # Add title key
 | 
			
		||||
            data["title"] = data["metadata"]["title"]
 | 
			
		||||
 | 
			
		||||
            # Mark whether it's deprecated
 | 
			
		||||
            data["deprecated"] = data["metadata"].get("deprecated", False)
 | 
			
		||||
        else:
 | 
			
		||||
            data["title"] = mod
 | 
			
		||||
 | 
			
		||||
            # Assume not deprecated if we can't find it
 | 
			
		||||
            data["deprecated"] = False
 | 
			
		||||
 | 
			
		||||
        if "latest" in data:
 | 
			
		||||
            self._resolve_dependencies(mod)
 | 
			
		||||
 | 
			
		||||
    def _resolve_dependencies(self, mod: str):
 | 
			
		||||
        """
 | 
			
		||||
        Processes the dependency list for this mod and returns an array
 | 
			
		||||
        listing those which are not currently enabled. Note that this skips
 | 
			
		||||
        exclusions and optional dependencies. (! and ?)
 | 
			
		||||
        """
 | 
			
		||||
        data = self.mods[mod]
 | 
			
		||||
        if "latest" in data:
 | 
			
		||||
            data["missing_deps"] = []
 | 
			
		||||
            data["dependencies"] = {}
 | 
			
		||||
            dependencies = data["latest"]["info_json"]["dependencies"]
 | 
			
		||||
            # Preparation for future explicit version matching
 | 
			
		||||
            dep_pattern = re.compile(r"^([\w -]+) ([<=>][=])? (\d+[.]\d+[.]\d+)$")
 | 
			
		||||
            for dep_entry in dependencies:
 | 
			
		||||
                match = dep_pattern.fullmatch(dep_entry)
 | 
			
		||||
                if match:
 | 
			
		||||
                    dep = {}
 | 
			
		||||
                    dep_name = match.group(1)
 | 
			
		||||
                    if dep_name == "base":
 | 
			
		||||
                        continue
 | 
			
		||||
                    dep["argument"] = match.group(2)
 | 
			
		||||
                    dep["version"] = match.group(3)
 | 
			
		||||
                    data["dependencies"][match.group(1)] = dep
 | 
			
		||||
 | 
			
		||||
            for dep_name in data["dependencies"].keys():
 | 
			
		||||
                if dep_name not in self.mods:
 | 
			
		||||
                    data["missing_deps"].append(dep_name)
 | 
			
		||||
 | 
			
		||||
    def _parse_mod_list(self):
 | 
			
		||||
        """Process the mod-list.json within mod_path."""
 | 
			
		||||
        mod_list_path = os.path.join(self.mod_path, "mod-list.json")
 | 
			
		||||
        try:
 | 
			
		||||
            settings_fp = open(mod_list_path, "r")
 | 
			
		||||
            mod_json = json.load(settings_fp)
 | 
			
		||||
            self.mods = {}
 | 
			
		||||
            if "mods" in mod_json:
 | 
			
		||||
                for mod in mod_json["mods"]:
 | 
			
		||||
                    entry = {}
 | 
			
		||||
                    entry["enabled"] = mod["enabled"]
 | 
			
		||||
                    self.mods[mod["name"]] = entry
 | 
			
		||||
            else:
 | 
			
		||||
                print(
 | 
			
		||||
                    "Invalid mod-list.json file \
 | 
			
		||||
                      '{path}'!".format(
 | 
			
		||||
                        path=mod_list_path
 | 
			
		||||
                    ),
 | 
			
		||||
                    file=sys.stderr,
 | 
			
		||||
                )
 | 
			
		||||
                sys.exit(1)
 | 
			
		||||
 | 
			
		||||
            # Remove the 'base' mod as it's not relevant to this process
 | 
			
		||||
            if "base" in self.mods:
 | 
			
		||||
                del self.mods["base"]
 | 
			
		||||
        except IOError as error:
 | 
			
		||||
            errmsg = ("error: failed to open file '{fname}': " "{errstr}").format(
 | 
			
		||||
                fname=mod_list_path, errstr=error.strerror
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
        except json.JSONDecodeError as error:
 | 
			
		||||
            errmsg = ("error: failed to parse json file '{fname}': " "{errstr}").format(
 | 
			
		||||
                fname=mod_list_path, errstr=error.msg
 | 
			
		||||
            )
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        # Collect the installed state & versions
 | 
			
		||||
        self.mod_files = glob.glob("{mod_path}/*.zip".format(mod_path=self.mod_path))
 | 
			
		||||
        installed_mods = {}
 | 
			
		||||
        mod_pattern = re.compile(self.MOD_FILE_PATTERN)
 | 
			
		||||
        for entry in self.mod_files:
 | 
			
		||||
            basename = os.path.basename(entry)
 | 
			
		||||
            match = mod_pattern.fullmatch(basename)
 | 
			
		||||
            if match:
 | 
			
		||||
                installed_mods[match.group(1)] = match.group(2)
 | 
			
		||||
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            if mod in installed_mods:
 | 
			
		||||
                data["installed"] = True
 | 
			
		||||
                data["version"] = installed_mods[mod]
 | 
			
		||||
            else:
 | 
			
		||||
                data["installed"] = False
 | 
			
		||||
 | 
			
		||||
    def _update_mod_list(self):
 | 
			
		||||
        """
 | 
			
		||||
        Generates an updated mod-list.json file which takes into account any
 | 
			
		||||
        newly added dependencies.
 | 
			
		||||
        """
 | 
			
		||||
        # Build the simplified object for json output
 | 
			
		||||
        mod_list_output = {}
 | 
			
		||||
        mod_list_output["mods"] = []
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            mod_entry = {}
 | 
			
		||||
            mod_entry["name"] = mod
 | 
			
		||||
            mod_entry["enabled"] = data["enabled"]
 | 
			
		||||
            mod_list_output["mods"].append(mod_entry)
 | 
			
		||||
 | 
			
		||||
        # Rename the old mod-list file with a timestamp
 | 
			
		||||
        mod_list_path = os.path.join(self.mod_path, "mod-list.json")
 | 
			
		||||
        mod_list_backup_path = os.path.join(
 | 
			
		||||
            self.mod_path,
 | 
			
		||||
            "mod-list.{timestamp}.json".format(
 | 
			
		||||
                timestamp=self.timestamp.strftime("%Y-%m-%d_%H%M.%S")
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            os.rename(src=mod_list_path, dst=mod_list_backup_path)
 | 
			
		||||
        except IOError as error:
 | 
			
		||||
            errmsg = (
 | 
			
		||||
                "error: failed to rename file '{s}' to '{d}': " "{errstr}"
 | 
			
		||||
            ).format(s=mod_list_path, d=mod_list_backup_path, errstr=error.strerror)
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
        # Store the current mod list
 | 
			
		||||
        try:
 | 
			
		||||
            mod_list_fp = open(mod_list_path, "w")
 | 
			
		||||
            mod_list_fp.write(json.dumps(mod_list_output, indent=2, sort_keys=True))
 | 
			
		||||
        except IOError as error:
 | 
			
		||||
            errmsg = (
 | 
			
		||||
                "error: failed to store updated mod list file '{s}': " "{errstr}"
 | 
			
		||||
            ).format(s=mod_list_path, errstr=error.strerror)
 | 
			
		||||
            print(errmsg, file=sys.stderr)
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    def _determine_max_name_lengths(self):
 | 
			
		||||
        """Returns the length of the longest mod name"""
 | 
			
		||||
        max_mod_len = 0
 | 
			
		||||
        max_cver_len = 0
 | 
			
		||||
        max_lver_len = 0
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            mod_len = len(data["title"]) if self.title_mode else len(mod)
 | 
			
		||||
            max_mod_len = mod_len if mod_len > max_mod_len else max_mod_len
 | 
			
		||||
            cver_len = len(data["version"]) if data["installed"] else len("Version")
 | 
			
		||||
            max_cver_len = cver_len if cver_len > max_cver_len else max_cver_len
 | 
			
		||||
            lver_len = (
 | 
			
		||||
                len(data["latest"]["version"]) if "latest" in data else len("Version")
 | 
			
		||||
            )
 | 
			
		||||
            max_lver_len = lver_len if lver_len > max_lver_len else max_lver_len
 | 
			
		||||
 | 
			
		||||
        self.max_mod_len = max_mod_len
 | 
			
		||||
        self.max_cver_len = max_cver_len
 | 
			
		||||
        self.max_lver_len = max_lver_len
 | 
			
		||||
        self.max_ver_len = max_lver_len if max_lver_len > max_cver_len else max_cver_len
 | 
			
		||||
 | 
			
		||||
    def list(self):
 | 
			
		||||
        """Lists the mods installed on this server."""
 | 
			
		||||
        # Find the longest mod name
 | 
			
		||||
 | 
			
		||||
        print(
 | 
			
		||||
            "{:<{width}}\tenabled\tinstalled\tcurrent_v\tlatest_v".format(
 | 
			
		||||
                "mod_name", width=self.max_mod_len
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            print(
 | 
			
		||||
                "{:<{width}}\t{enbld}\t{inst}\t\t{cver}\t\t{lver}".format(
 | 
			
		||||
                    mod,
 | 
			
		||||
                    enbld=str(data["enabled"]),
 | 
			
		||||
                    inst=str(data["installed"]),
 | 
			
		||||
                    cver=data["version"] if data["installed"] else "N/A",
 | 
			
		||||
                    lver=data["latest"]["version"] if "latest" in data else "N/A",
 | 
			
		||||
                    width=self.max_mod_len,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def override_credentials(self, username: str, token: str):
 | 
			
		||||
        """Replaces the values provided in server-settings.json or player-data.json"""
 | 
			
		||||
        if username is not None:
 | 
			
		||||
            self.username = username
 | 
			
		||||
        if token is not None:
 | 
			
		||||
            self.token = token
 | 
			
		||||
 | 
			
		||||
    def _print_mod_message(
 | 
			
		||||
        self, mod: str, version: str, action: str, result: str, message: str, data: hash
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        Prints a mod status message using the provided parameters.
 | 
			
		||||
        """
 | 
			
		||||
        if data is not None:
 | 
			
		||||
            title = data["title"] if self.title_mode else mod
 | 
			
		||||
        else:
 | 
			
		||||
            title = mod
 | 
			
		||||
 | 
			
		||||
        output_string = (
 | 
			
		||||
            "{title:<{mwidth}}\t{version:<{vwidth}}"
 | 
			
		||||
            "\t{action:<10}\t{result:<10}\t{message}"
 | 
			
		||||
        ).format(
 | 
			
		||||
            title=title,
 | 
			
		||||
            version=version,
 | 
			
		||||
            action=action,
 | 
			
		||||
            result=result,
 | 
			
		||||
            message=message,
 | 
			
		||||
            vwidth=self.max_ver_len,
 | 
			
		||||
            mwidth=self.max_mod_len,
 | 
			
		||||
        )
 | 
			
		||||
        print(output_string)
 | 
			
		||||
 | 
			
		||||
    def update(self):
 | 
			
		||||
        """
 | 
			
		||||
        Updates all mods currently installed on this server to the latest
 | 
			
		||||
        release
 | 
			
		||||
        """
 | 
			
		||||
        self._print_mod_message("Mod", "Version", "Action", "Result", "Message", None)
 | 
			
		||||
 | 
			
		||||
        for mod, data in self.mods.items():
 | 
			
		||||
            version = data["version"] if data["installed"] else "N/A"
 | 
			
		||||
            if "metadata" not in data:
 | 
			
		||||
                self._print_mod_message(
 | 
			
		||||
                    mod=mod,
 | 
			
		||||
                    version=version,
 | 
			
		||||
                    action="Skip",
 | 
			
		||||
                    result="N/A",
 | 
			
		||||
                    message="Missing metadata, skipping update!",
 | 
			
		||||
                    data=data,
 | 
			
		||||
                )
 | 
			
		||||
                continue
 | 
			
		||||
            if "latest" not in data:
 | 
			
		||||
                message = (
 | 
			
		||||
                    "No release found for factorio '{version}', skipping update!"
 | 
			
		||||
                ).format(version=self.fact_version["release"])
 | 
			
		||||
                self._print_mod_message(
 | 
			
		||||
                    mod=mod,
 | 
			
		||||
                    version=version,
 | 
			
		||||
                    action="Skip",
 | 
			
		||||
                    result="N/A",
 | 
			
		||||
                    message=message,
 | 
			
		||||
                    data=data,
 | 
			
		||||
                )
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            self._prune_old_releases(mod)
 | 
			
		||||
            self._download_latest_release(mod)
 | 
			
		||||
 | 
			
		||||
        # Update the mod list file
 | 
			
		||||
        self._update_mod_list()
 | 
			
		||||
 | 
			
		||||
    def _prune_old_releases(self, mod: str):
 | 
			
		||||
        """
 | 
			
		||||
        Deletes any locally installed versions older than the latest release.
 | 
			
		||||
 | 
			
		||||
        Keyword Arguments:
 | 
			
		||||
        mod -- name of the target to update
 | 
			
		||||
        """
 | 
			
		||||
        data = self.mods[mod]
 | 
			
		||||
        latest_version = data["latest"]["version"]
 | 
			
		||||
 | 
			
		||||
        # Declare the patterns
 | 
			
		||||
        mod_pattern = re.compile(
 | 
			
		||||
            "^{mod}_({ver})[.]zip$".format(mod=mod, ver=self.MOD_VERSION_PATTERN)
 | 
			
		||||
        )
 | 
			
		||||
        version_pattern = re.compile(
 | 
			
		||||
            "^{mod}_{ver}[.]zip$".format(mod=mod, ver=latest_version)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Build the parse list
 | 
			
		||||
        basenames = [os.path.basename(x) for x in self.mod_files]
 | 
			
		||||
        inst_rels = [x for x in basenames if mod_pattern.fullmatch(x)]
 | 
			
		||||
        for rel in inst_rels:
 | 
			
		||||
            if version_pattern.fullmatch(rel):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            match = mod_pattern.fullmatch(rel)
 | 
			
		||||
            if match:
 | 
			
		||||
                rel_ver = match.group(1)
 | 
			
		||||
            else:
 | 
			
		||||
                rel_ver = "TBD"
 | 
			
		||||
 | 
			
		||||
            rel_path = os.path.join(self.mod_path, rel)
 | 
			
		||||
            try:
 | 
			
		||||
                os.remove(rel_path)
 | 
			
		||||
                result = "Success"
 | 
			
		||||
                message = ""
 | 
			
		||||
            except OSError as error:
 | 
			
		||||
                message = ("error: failed to remove '{fname}': " "{errstr}").format(
 | 
			
		||||
                    fname=rel_path, errstr=error.strerror
 | 
			
		||||
                )
 | 
			
		||||
                result = "Failure"
 | 
			
		||||
 | 
			
		||||
            self._print_mod_message(
 | 
			
		||||
                mod=mod,
 | 
			
		||||
                version=rel_ver,
 | 
			
		||||
                action="Remove",
 | 
			
		||||
                result=result,
 | 
			
		||||
                message=message,
 | 
			
		||||
                data=data,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def _download_latest_release(self, mod: str):
 | 
			
		||||
        """
 | 
			
		||||
        Retrieves the latest version of the specified mod compatible with the
 | 
			
		||||
        factorio release present on this server.
 | 
			
		||||
 | 
			
		||||
        Keyword Arguments:
 | 
			
		||||
        mod -- name of the target to update
 | 
			
		||||
        """
 | 
			
		||||
        data = self.mods[mod]
 | 
			
		||||
        latest = data["latest"]
 | 
			
		||||
        target = os.path.join(self.mod_path, latest["file_name"])
 | 
			
		||||
 | 
			
		||||
        validate = download = False
 | 
			
		||||
 | 
			
		||||
        v_cur = data["version"] if "version" in data else "N/A"
 | 
			
		||||
        v_new = latest["version"]
 | 
			
		||||
        if data["installed"]:
 | 
			
		||||
            if v_new == v_cur:
 | 
			
		||||
                validate = True
 | 
			
		||||
            else:
 | 
			
		||||
                message = "Updating from '{v_cur}'".format(v_cur=v_cur)
 | 
			
		||||
                download = True
 | 
			
		||||
        else:
 | 
			
		||||
            message = "Downloading initial release '{v_new}'".format(v_new=v_new)
 | 
			
		||||
            download = True
 | 
			
		||||
 | 
			
		||||
        if validate:
 | 
			
		||||
            if _validate_hash(latest["sha1"], target):
 | 
			
		||||
                result = "Success"
 | 
			
		||||
                message = "Deprecated mod" if data["deprecated"] else ""
 | 
			
		||||
            else:
 | 
			
		||||
                result = "Failure"
 | 
			
		||||
                download = True
 | 
			
		||||
                message = "Validation failed, downloading again"
 | 
			
		||||
            self._print_mod_message(
 | 
			
		||||
                mod=mod,
 | 
			
		||||
                version=v_cur,
 | 
			
		||||
                action="Validate",
 | 
			
		||||
                result=result,
 | 
			
		||||
                message=message,
 | 
			
		||||
                data=data,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        if download:
 | 
			
		||||
            creds = {"username": self.username, "token": self.token}
 | 
			
		||||
            dl_url = self.mod_server_url + latest["download_url"]
 | 
			
		||||
            with requests.get(dl_url, params=creds, stream=True) as req:
 | 
			
		||||
                if req.status_code == 200:
 | 
			
		||||
                    with open(target, "wb") as target_file:
 | 
			
		||||
                        shutil.copyfileobj(req.raw, target_file)
 | 
			
		||||
                        target_file.flush()
 | 
			
		||||
                    if _validate_hash(latest["sha1"], target):
 | 
			
		||||
                        result = "Success"
 | 
			
		||||
                    else:
 | 
			
		||||
                        result = "Failure"
 | 
			
		||||
                        message = "Download did not match checksum!"
 | 
			
		||||
                elif req.status_code == 403:
 | 
			
		||||
                    message = (
 | 
			
		||||
                        "Failed to download, credentials not accepted. "
 | 
			
		||||
                        + "Check your username/token"
 | 
			
		||||
                    )
 | 
			
		||||
                    result = "Failure"
 | 
			
		||||
                else:
 | 
			
		||||
                    message = "Unable to retrieve, status code: " + str(req.status_code)
 | 
			
		||||
                    result = "Failure"
 | 
			
		||||
 | 
			
		||||
            self._print_mod_message(
 | 
			
		||||
                mod=mod,
 | 
			
		||||
                version=v_new,
 | 
			
		||||
                action="Download",
 | 
			
		||||
                result=result,
 | 
			
		||||
                message=message,
 | 
			
		||||
                data=data,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    DESC_TEXT = "Updates mods for a target factorio installation"
 | 
			
		||||
    PARSER = argparse.ArgumentParser(description=DESC_TEXT)
 | 
			
		||||
    # Username
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "-u",
 | 
			
		||||
        "--username",
 | 
			
		||||
        dest="username",
 | 
			
		||||
        help="factorio.com username overriding server-settings.json/player-data.json",
 | 
			
		||||
    )
 | 
			
		||||
    # Token
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "-t",
 | 
			
		||||
        "--token",
 | 
			
		||||
        dest="token",
 | 
			
		||||
        help="factorio.com API token overriding server-settings.json/player-data.json",
 | 
			
		||||
    )
 | 
			
		||||
    # Title format
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "--print-titles",
 | 
			
		||||
        dest="title_mode",
 | 
			
		||||
        default=False,
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="When true, print the mod title instead of the api name",
 | 
			
		||||
    )
 | 
			
		||||
    # Server Settings
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "-s",
 | 
			
		||||
        "--server-settings",
 | 
			
		||||
        dest="settings_path",
 | 
			
		||||
        required=False,
 | 
			
		||||
        help=(
 | 
			
		||||
            "Absolute path to the server-settings.json file "
 | 
			
		||||
            + "(overrides player-data.json)"
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    # Player Data
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "-d",
 | 
			
		||||
        "--player-data",
 | 
			
		||||
        dest="data_path",
 | 
			
		||||
        required=False,
 | 
			
		||||
        help="Absolute path to the player-data.json file",
 | 
			
		||||
    )
 | 
			
		||||
    # Factorio mod directory
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "-m",
 | 
			
		||||
        "--mod-directory",
 | 
			
		||||
        dest="mod_path",
 | 
			
		||||
        required=True,
 | 
			
		||||
        help="Absolute path to the mod directory",
 | 
			
		||||
    )
 | 
			
		||||
    # Factorio binary absolute path
 | 
			
		||||
    PARSER.add_argument(
 | 
			
		||||
        "--fact-path",
 | 
			
		||||
        dest="fact_path",
 | 
			
		||||
        required=True,
 | 
			
		||||
        help="Absolute path to the factorio binary",
 | 
			
		||||
    )
 | 
			
		||||
    # Possible Execution modes
 | 
			
		||||
    MODE_GROUP = PARSER.add_mutually_exclusive_group(required=True)
 | 
			
		||||
    MODE_GROUP.add_argument(
 | 
			
		||||
        "--list",
 | 
			
		||||
        dest="mode",
 | 
			
		||||
        action="store_const",
 | 
			
		||||
        const=ModUpdater.Mode.LIST,
 | 
			
		||||
        help="List the currently installed mods with versions",
 | 
			
		||||
    )
 | 
			
		||||
    MODE_GROUP.add_argument(
 | 
			
		||||
        "--update",
 | 
			
		||||
        dest="mode",
 | 
			
		||||
        action="store_const",
 | 
			
		||||
        const=ModUpdater.Mode.UPDATE,
 | 
			
		||||
        help="Update all mods to their latest release",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    ARGS = PARSER.parse_args()
 | 
			
		||||
    UPDATER = ModUpdater(
 | 
			
		||||
        settings_path=ARGS.settings_path,
 | 
			
		||||
        data_path=ARGS.data_path,
 | 
			
		||||
        mod_path=ARGS.mod_path,
 | 
			
		||||
        fact_path=ARGS.fact_path,
 | 
			
		||||
        creds={"username": ARGS.username, "token": ARGS.token},
 | 
			
		||||
        title_mode=ARGS.title_mode,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if ARGS.mode == ModUpdater.Mode.LIST:
 | 
			
		||||
        UPDATER.list()
 | 
			
		||||
    elif ARGS.mode == ModUpdater.Mode.UPDATE:
 | 
			
		||||
        UPDATER.update()
 | 
			
		||||
		Loading…
	
	Add table
		
		Reference in a new issue