├── .editorconfig ├── .flake8 ├── .github_changelog_generator ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── README.md ├── mod_updater.py └── pyproject.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.py] 9 | line_length = 88 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203, W503 4 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | user=pdemonaco 2 | project=factorio-mod-updater 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | samples 2 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [DESIGN] 2 | max-attributes=12 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.5](https://github.com/pdemonaco/factorio-mod-updater/tree/0.2.5) (2024-10-21) 4 | 5 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.2.4...0.2.5) 6 | 7 | **Closed issues:** 8 | 9 | - Dependency Resolution Fails [\#25](https://github.com/pdemonaco/factorio-mod-updater/issues/25) 10 | 11 | ## [0.2.4](https://github.com/pdemonaco/factorio-mod-updater/tree/0.2.4) (2021-03-27) 12 | 13 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.2.3...0.2.4) 14 | 15 | **Closed issues:** 16 | 17 | - username & password in server-settings.json CPU 100% no server start [\#21](https://github.com/pdemonaco/factorio-mod-updater/issues/21) 18 | - auto-enumeration not supported in older versions of python3 [\#19](https://github.com/pdemonaco/factorio-mod-updater/issues/19) 19 | 20 | **Merged pull requests:** 21 | 22 | - 1.1 and 1.0 aren't compatible. [\#23](https://github.com/pdemonaco/factorio-mod-updater/pull/23) ([clarfonthey](https://github.com/clarfonthey)) 23 | - Formatting, player-data.json support, show deprecated mods [\#22](https://github.com/pdemonaco/factorio-mod-updater/pull/22) ([clarfonthey](https://github.com/clarfonthey)) 24 | 25 | ## [0.2.3](https://github.com/pdemonaco/factorio-mod-updater/tree/0.2.3) (2020-09-05) 26 | 27 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.2.2...0.2.3) 28 | 29 | **Closed issues:** 30 | 31 | - 0.18 mods are compatible with 1.0, but they won't download through updater. [\#16](https://github.com/pdemonaco/factorio-mod-updater/issues/16) 32 | - Automatically enables all mods, including disabled ones [\#13](https://github.com/pdemonaco/factorio-mod-updater/issues/13) 33 | 34 | **Merged pull requests:** 35 | 36 | - feat: make title output optional [\#18](https://github.com/pdemonaco/factorio-mod-updater/pull/18) ([pdemonaco](https://github.com/pdemonaco)) 37 | - Display title instead of internal name, allow 0.18 mods on 1.0 [\#15](https://github.com/pdemonaco/factorio-mod-updater/pull/15) ([clarfonthey](https://github.com/clarfonthey)) 38 | - fix: enable/disable status from mod-list.json should persist [\#14](https://github.com/pdemonaco/factorio-mod-updater/pull/14) ([clarfonthey](https://github.com/clarfonthey)) 39 | - fix: "Install requests" URL in README [\#12](https://github.com/pdemonaco/factorio-mod-updater/pull/12) ([jessedc](https://github.com/jessedc)) 40 | - Enhancement: Some additional information in mod-download failure cases [\#11](https://github.com/pdemonaco/factorio-mod-updater/pull/11) ([jylee4](https://github.com/jylee4)) 41 | 42 | ## [0.2.2](https://github.com/pdemonaco/factorio-mod-updater/tree/0.2.2) (2020-04-12) 43 | 44 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.2.1...0.2.2) 45 | 46 | **Fixed bugs:** 47 | 48 | - can't update again [\#9](https://github.com/pdemonaco/factorio-mod-updater/issues/9) 49 | 50 | **Merged pull requests:** 51 | 52 | - fix: add missing message [\#10](https://github.com/pdemonaco/factorio-mod-updater/pull/10) ([pdemonaco](https://github.com/pdemonaco)) 53 | 54 | ## [0.2.1](https://github.com/pdemonaco/factorio-mod-updater/tree/0.2.1) (2020-04-11) 55 | 56 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.2.0...0.2.1) 57 | 58 | **Implemented enhancements:** 59 | 60 | - feat: add fixed width output to the update command [\#7](https://github.com/pdemonaco/factorio-mod-updater/issues/7) 61 | - feat: add mod dependency tree resolution [\#4](https://github.com/pdemonaco/factorio-mod-updater/issues/4) 62 | 63 | **Fixed bugs:** 64 | 65 | - bug: Mod names which include underscores are handled incorrectly [\#6](https://github.com/pdemonaco/factorio-mod-updater/issues/6) 66 | 67 | **Merged pull requests:** 68 | 69 | - Underscores and Beautiful Output [\#8](https://github.com/pdemonaco/factorio-mod-updater/pull/8) ([pdemonaco](https://github.com/pdemonaco)) 70 | 71 | ## [0.2.0](https://github.com/pdemonaco/factorio-mod-updater/tree/0.2.0) (2020-04-05) 72 | 73 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.1.1...0.2.0) 74 | 75 | **Merged pull requests:** 76 | 77 | - 4 feat add dependency tree resolution [\#5](https://github.com/pdemonaco/factorio-mod-updater/pull/5) ([pdemonaco](https://github.com/pdemonaco)) 78 | 79 | ## [0.1.1](https://github.com/pdemonaco/factorio-mod-updater/tree/0.1.1) (2020-02-02) 80 | 81 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/0.1.0...0.1.1) 82 | 83 | **Fixed bugs:** 84 | 85 | - Missing check for empty array "matching\_releases" [\#2](https://github.com/pdemonaco/factorio-mod-updater/issues/2) 86 | 87 | **Merged pull requests:** 88 | 89 | - Bug: fix missing version scenario [\#3](https://github.com/pdemonaco/factorio-mod-updater/pull/3) ([pdemonaco](https://github.com/pdemonaco)) 90 | - maint: correcting settings path [\#1](https://github.com/pdemonaco/factorio-mod-updater/pull/1) ([Side2005](https://github.com/Side2005)) 91 | 92 | ## [0.1.0](https://github.com/pdemonaco/factorio-mod-updater/tree/0.1.0) (2019-04-13) 93 | 94 | [Full Changelog](https://github.com/pdemonaco/factorio-mod-updater/compare/22d08c70c8e38e2597fa7dbc20060f558e590d39...0.1.0) 95 | 96 | 97 | 98 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This module will automatically update all mods installed for a given instance of Factorio. 2 | 3 | Note that this is primarily intended for headless dedicated Linux servers. 4 | 5 | ## Features 6 | 7 | * Updates mods to the latest release based on mod-list.json 8 | * Removes all old versions of mods which are being updated 9 | * Limits releases to those compatible with the installed factorio version 10 | * Installs all required dependencies for the latest release of the currently enabled mods 11 | 12 | ## Installation 13 | 14 | 1. Ensure a python3 implementation is available via something like `command -v python3`. If it's missing here's a few potential installation routes: 15 | 16 | **Ubuntu/Debian** 17 | ```bash 18 | sudo apt install python3 -y 19 | ``` 20 | 21 | **RedHat Family (Fedora/CentOS/etc)** 22 | ```bash 23 | sudo yum install python36u -y 24 | ``` 25 | 26 | **Gentoo** 27 | ```bash 28 | # Shouldn't be necessary since you'll have python for portage 29 | emerge -vt python 30 | ``` 31 | 2. [Install requests](https://requests.readthedocs.io/en/master/user/install/#install) as described in their documentation. Or, on gentoo: 32 | 33 | ```bash 34 | emerge -vt dev-python/requests 35 | ``` 36 | 3. Download the latest release and you should be good to go. 37 | 38 | ## Usage 39 | 40 | Two modes are supported: 41 | 42 | * `--list` - lists all mods described by mod-list.json, their current version, and the latest release 43 | * `--update` - performs an update of all mods for the current server 44 | 45 | Here's a brief example of executing the command: 46 | 47 | ```bash 48 | ./mod_updater.py -s /opt/factorio/data/server-settings.json \ 49 | -m /opt/factorio/mods \ 50 | --fact-path /opt/factorio/bin/x64/factorio --update 51 | ``` 52 | 53 | ## Contributing 54 | 55 | A few notes about the code: 56 | 57 | * This script is designed to work with Python 3. Although there will be effort to avoid needlessly bumping the required version of Python 3, changes to make the script work on end-of-life versions of Python 3 are not supported. 58 | * To ensure consistency in the code, format with `black` before submitting pull requests. You can install black with `pip install black` and then run `black mod_updater.py` in the repo to autoformat. 59 | * On a best-effort basis, the script should pass all lints on `flake8` and `pylint`. You can also install these via pip and can run them with `flake8 mod_updater.py` and `pylint mod_updater.py` respectively. 60 | 61 | ## See Also 62 | 63 | * [Ruby Factorio Mod Updater](https://github.com/astevens/factorio-mod-updater) 64 | * [Factorio Server Updater](https://github.com/narc0tiq/factorio-updater) 65 | * [Factorio Init Script](https://github.com/Bisa/factorio-init) 66 | -------------------------------------------------------------------------------- /mod_updater.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | This module provides a simple method to manage updating and installing mods 4 | on a given factorio server. 5 | 6 | It is currently not intended to be imported and instead should be executed 7 | directly as a python script. 8 | """ 9 | import argparse 10 | from collections import OrderedDict 11 | from datetime import datetime 12 | from enum import Enum, auto 13 | import glob 14 | import hashlib 15 | import json 16 | import os 17 | import re 18 | import shutil 19 | import subprocess 20 | import sys 21 | 22 | # External URL processing library 23 | # http://docs.python-requests.org/en/master/user/quickstart/ 24 | import requests 25 | 26 | 27 | def _validate_hash(checksum: str, target: str, bsize: int = 65536) -> bool: 28 | """ 29 | Checks to see if the file specified by target matches the provided sha1 30 | checksum. 31 | 32 | Keyword Arguments: 33 | checksum -- sha1 digest to be matched 34 | target -- path to the file which must be validated 35 | """ 36 | hasher = hashlib.sha1() 37 | 38 | with open(target, "rb") as target_fp: 39 | block = target_fp.read(bsize) 40 | while len(block) > 0: 41 | hasher.update(block) 42 | block = target_fp.read(bsize) 43 | 44 | return hasher.hexdigest() == checksum 45 | 46 | 47 | def _version_match(installed: str, mod: str): 48 | """ 49 | Checks if factorio versions are compatible. 50 | """ 51 | version_regex = re.compile("(?P\\d+)\\.(?P\\d+)(?:.(?P\\d+))?") 52 | mod_groups = version_regex.search(mod).groupdict() 53 | installed_groups = version_regex.search(installed).groupdict() 54 | if installed.startswith("1.") and mod.startswith("0.18"): 55 | return True 56 | if "sub" in mod_groups: 57 | return installed_groups == mod_groups 58 | return ( 59 | installed_groups["major"] == mod_groups["major"] 60 | and installed_groups["minor"] == mod_groups["minor"] 61 | ) 62 | 63 | 64 | class ModUpdater: 65 | """ 66 | Internal class managing the current version and state of the mods on this 67 | server. 68 | """ 69 | 70 | MOD_VERSION_PATTERN = r"\d+[.]\d+[.]\d+" 71 | MOD_FILE_PATTERN = "^(.*)_({version})[.]zip$".format(version=MOD_VERSION_PATTERN) 72 | 73 | class Mode(Enum): 74 | """Possible execution modes""" 75 | 76 | LIST = auto() 77 | UPDATE = auto() 78 | 79 | def __init__( 80 | self, 81 | settings_path: str, 82 | data_path: str, 83 | mod_path: str, 84 | fact_path: str, 85 | creds: hash, 86 | title_mode: bool, 87 | ): 88 | """ 89 | Initialize the updater class with all mandatory and optional arguments. 90 | 91 | Keyword arguments: 92 | settings_path -- absolute path to the server-settings.json file 93 | mod_path -- absolute path to the factorio mod directory 94 | fact_ver -- local factorio version 95 | """ 96 | self.mod_server_url = "https://mods.factorio.com" 97 | self.mod_path = mod_path 98 | self.timestamp = datetime.utcnow() 99 | self.title_mode = title_mode 100 | 101 | # Get the credentials to download mods 102 | if settings_path is not None: 103 | self.settings = self._parse_settings(settings_path) 104 | else: 105 | self.settings = {} 106 | if data_path is not None: 107 | self.data = self._parse_settings(data_path) 108 | else: 109 | self.data = {} 110 | 111 | # Parse username and token 112 | if "username" in creds and creds["username"] is not None: 113 | self.username = creds["username"] 114 | elif "username" in self.settings: 115 | self.username = self.settings["username"] 116 | elif "service-username" in self.data: 117 | self.username = self.data["service-username"] 118 | else: 119 | self.token = None 120 | 121 | if "token" in creds and creds["token"] is not None: 122 | self.token = creds["token"] 123 | elif "token" in self.settings: 124 | self.token = self.settings["token"] 125 | elif "service-token" in self.data: 126 | self.token = self.data["service-token"] 127 | else: 128 | self.token = None 129 | 130 | # Ensure username and token were specified 131 | if self.username is None or self.username == "": 132 | errmsg = ( 133 | "error: username not specified in " 134 | + "server-settings.json, player-data.json, or cli!" 135 | ) 136 | print(errmsg, file=sys.stderr) 137 | sys.exit(1) 138 | 139 | if self.token is None or self.token == "": 140 | errmsg = ( 141 | "error: token not specified in " 142 | + "server-settings.json, player-data.json, or cli!" 143 | ) 144 | print(errmsg, file=sys.stderr) 145 | sys.exit(1) 146 | 147 | # Begin processing 148 | self._determine_version(fact_path) 149 | self._parse_mod_list() 150 | self._retrieve_metadata() 151 | self._determine_max_name_lengths() 152 | if self.title_mode: 153 | self.mods = OrderedDict( 154 | sorted(self.mods.items(), key=lambda mod: mod[1]["title"]) 155 | ) 156 | else: 157 | self.mods = OrderedDict(sorted(self.mods.items())) 158 | 159 | def _determine_version(self, fact_path: str): 160 | """Determine the local factorio version""" 161 | if not os.path.exists(fact_path): 162 | errmsg = "error: factorio binary '{fpath_path}' does not exist!" 163 | print(errmsg, file=sys.stderr) 164 | sys.exit(1) 165 | 166 | try: 167 | output = subprocess.check_output( 168 | [fact_path, "--version"], universal_newlines=True 169 | ) 170 | ver_re = re.compile(r"Version: (\d+)[.](\d+)[.](\d+) .*\n", re.RegexFlag.M) 171 | match = ver_re.match(output) 172 | if match: 173 | version = {} 174 | version["major"] = match.group(1) 175 | version["minor"] = match.group(2) 176 | version["patch"] = match.group(3) 177 | version["release"] = "{}.{}".format(version["major"], version["minor"]) 178 | self.fact_version = version 179 | else: 180 | errmsg = "Unable to parse version from:\n{output}".format(output=output) 181 | print(errmsg, file=sys.stderr) 182 | sys.exit("1") 183 | 184 | except subprocess.CalledProcessError as error: 185 | errmsg = ("error: failed to run '{fpath} --version': " "{errstr}").format( 186 | fpath=fact_path, errstr=error.stderr 187 | ) 188 | print(errmsg, file=sys.stderr) 189 | sys.exit(1) 190 | 191 | print( 192 | "Factorio Release: {release}\n".format(release=self.fact_version["release"]) 193 | ) 194 | 195 | @staticmethod 196 | def _parse_settings(config_path: str): 197 | """Process the specified server-settings.json or player-data.json file.""" 198 | try: 199 | with open(config_path, "r") as config_fp: 200 | return json.load(config_fp) 201 | except IOError as error: 202 | errmsg = ("error: failed to open file '{fname}': " "{errstr}").format( 203 | fname=config_path, errstr=error.strerror 204 | ) 205 | print(errmsg, file=sys.stderr) 206 | sys.exit(1) 207 | except json.JSONDecodeError as error: 208 | errmsg = ("error: failed to parse json file '{fname}': " "{errstr}").format( 209 | fname=config_path, errstr=error.msg 210 | ) 211 | print(errmsg, file=sys.stderr) 212 | sys.exit(1) 213 | 214 | def _retrieve_metadata(self): 215 | """ 216 | Pull the latest metadata for each mod from the factorio server 217 | See https://wiki.factorio.com/Mod_portal_API for details 218 | """ 219 | print("Retrieving metadata", end="") 220 | for mod, data in self.mods.items(): 221 | self._retrieve_mod_metadata(mod) 222 | print(".", end="", flush=True) 223 | print("complete!\n") 224 | 225 | # Add missing dependencies to the overall list 226 | while True: 227 | missing_mods = [] 228 | for mod, data in self.mods.items(): 229 | if "missing_deps" in data: 230 | missing_mods.extend(data["missing_deps"]) 231 | 232 | unique_missing = set(missing_mods) 233 | for mod in self.mods.keys(): 234 | if mod in unique_missing: 235 | unique_missing.remove(mod) 236 | if len(unique_missing) == 0: 237 | break 238 | for mod in unique_missing: 239 | entry = {} 240 | entry["enabled"] = True 241 | entry["installed"] = False 242 | self.mods[mod] = entry 243 | self._retrieve_mod_metadata(mod) 244 | print("Info: adding missing dependency {dep}".format(dep=mod)) 245 | 246 | for mod, data in self.mods.items(): 247 | if "metadata" not in data: 248 | warnmsg = ( 249 | "Warning: Unable to retrieve metadata for" 250 | " {mod}, skipped!".format(mod=mod) 251 | ) 252 | print(warnmsg) 253 | 254 | def _retrieve_mod_metadata(self, mod: str): 255 | """ 256 | Attempts to retrieve the metadata for the target mod. If found, the 257 | data object is updated with the 'metadata' key and the 'latest' keys. 258 | """ 259 | data = self.mods[mod] 260 | mod_url = self.mod_server_url + "/api/mods/" + mod + "/full" 261 | with requests.get(mod_url) as req: 262 | if req.status_code == 200: 263 | data["metadata"] = req.json() 264 | 265 | if "metadata" in data: 266 | # Find the latest release for this version of Factorio 267 | matching_releases = [] 268 | for rel in data["metadata"]["releases"]: 269 | rel_ver = rel["info_json"]["factorio_version"] 270 | if _version_match(installed=self.fact_version["release"], mod=rel_ver): 271 | matching_releases.append(rel) 272 | 273 | if len(matching_releases) > 0: 274 | data["latest"] = matching_releases[-1] 275 | 276 | # Add title key 277 | data["title"] = data["metadata"]["title"] 278 | 279 | # Mark whether it's deprecated 280 | data["deprecated"] = data["metadata"].get("deprecated", False) 281 | else: 282 | data["title"] = mod 283 | 284 | # Assume not deprecated if we can't find it 285 | data["deprecated"] = False 286 | 287 | if "latest" in data: 288 | self._resolve_dependencies(mod) 289 | 290 | def _resolve_dependencies(self, mod: str): 291 | """ 292 | Processes the dependency list for this mod and returns an array 293 | listing those which are not currently enabled. Note that this skips 294 | exclusions and optional dependencies. (! and ?) 295 | """ 296 | data = self.mods[mod] 297 | if "latest" in data: 298 | data["missing_deps"] = [] 299 | data["dependencies"] = {} 300 | dependencies = data["latest"]["info_json"]["dependencies"] 301 | # Preparation for future explicit version matching 302 | dep_pattern = re.compile( 303 | r"^(?:~ )?(?P[\w -]+)(?: (?P(?:[<>]=?)|=) \ 304 | (?P\d+[.]\d+[.]\d+))?$" 305 | ) 306 | for dep_entry in dependencies: 307 | match = dep_pattern.fullmatch(dep_entry) 308 | if match: 309 | dep = {} 310 | dep_name = match.group("name") 311 | if dep_name == "base": 312 | continue 313 | dep["argument"] = match.group("arg") 314 | dep["version"] = match.group("ver") 315 | data["dependencies"][match.group(1)] = dep 316 | 317 | for dep_name in data["dependencies"].keys(): 318 | if dep_name not in self.mods: 319 | data["missing_deps"].append(dep_name) 320 | 321 | def _parse_mod_list(self): 322 | """Process the mod-list.json within mod_path.""" 323 | mod_list_path = os.path.join(self.mod_path, "mod-list.json") 324 | try: 325 | settings_fp = open(mod_list_path, "r") 326 | mod_json = json.load(settings_fp) 327 | self.mods = {} 328 | if "mods" in mod_json: 329 | for mod in mod_json["mods"]: 330 | entry = {} 331 | entry["enabled"] = mod["enabled"] 332 | self.mods[mod["name"]] = entry 333 | else: 334 | print( 335 | "Invalid mod-list.json file \ 336 | '{path}'!".format( 337 | path=mod_list_path 338 | ), 339 | file=sys.stderr, 340 | ) 341 | sys.exit(1) 342 | 343 | # Remove the 'base' mod as it's not relevant to this process 344 | if "base" in self.mods: 345 | del self.mods["base"] 346 | except IOError as error: 347 | errmsg = ("error: failed to open file '{fname}': " "{errstr}").format( 348 | fname=mod_list_path, errstr=error.strerror 349 | ) 350 | print(errmsg, file=sys.stderr) 351 | sys.exit(1) 352 | except json.JSONDecodeError as error: 353 | errmsg = ("error: failed to parse json file '{fname}': " "{errstr}").format( 354 | fname=mod_list_path, errstr=error.msg 355 | ) 356 | print(errmsg, file=sys.stderr) 357 | sys.exit(1) 358 | 359 | # Collect the installed state & versions 360 | self.mod_files = glob.glob("{mod_path}/*.zip".format(mod_path=self.mod_path)) 361 | installed_mods = {} 362 | mod_pattern = re.compile(self.MOD_FILE_PATTERN) 363 | for entry in self.mod_files: 364 | basename = os.path.basename(entry) 365 | match = mod_pattern.fullmatch(basename) 366 | if match: 367 | installed_mods[match.group(1)] = match.group(2) 368 | 369 | for mod, data in self.mods.items(): 370 | if mod in installed_mods: 371 | data["installed"] = True 372 | data["version"] = installed_mods[mod] 373 | else: 374 | data["installed"] = False 375 | 376 | def _update_mod_list(self): 377 | """ 378 | Generates an updated mod-list.json file which takes into account any 379 | newly added dependencies. 380 | """ 381 | # Build the simplified object for json output 382 | mod_list_output = {} 383 | mod_list_output["mods"] = [] 384 | for mod, data in self.mods.items(): 385 | mod_entry = {} 386 | mod_entry["name"] = mod 387 | mod_entry["enabled"] = data["enabled"] 388 | mod_list_output["mods"].append(mod_entry) 389 | 390 | # Rename the old mod-list file with a timestamp 391 | mod_list_path = os.path.join(self.mod_path, "mod-list.json") 392 | mod_list_backup_path = os.path.join( 393 | self.mod_path, 394 | "mod-list.{timestamp}.json".format( 395 | timestamp=self.timestamp.strftime("%Y-%m-%d_%H%M.%S") 396 | ), 397 | ) 398 | try: 399 | os.rename(src=mod_list_path, dst=mod_list_backup_path) 400 | except IOError as error: 401 | errmsg = ( 402 | "error: failed to rename file '{s}' to '{d}': " "{errstr}" 403 | ).format(s=mod_list_path, d=mod_list_backup_path, errstr=error.strerror) 404 | print(errmsg, file=sys.stderr) 405 | sys.exit(1) 406 | 407 | # Store the current mod list 408 | try: 409 | mod_list_fp = open(mod_list_path, "w") 410 | mod_list_fp.write(json.dumps(mod_list_output, indent=2, sort_keys=True)) 411 | except IOError as error: 412 | errmsg = ( 413 | "error: failed to store updated mod list file '{s}': " "{errstr}" 414 | ).format(s=mod_list_path, errstr=error.strerror) 415 | print(errmsg, file=sys.stderr) 416 | sys.exit(1) 417 | 418 | def _determine_max_name_lengths(self): 419 | """Returns the length of the longest mod name""" 420 | max_mod_len = 0 421 | max_cver_len = 0 422 | max_lver_len = 0 423 | for mod, data in self.mods.items(): 424 | mod_len = len(data["title"]) if self.title_mode else len(mod) 425 | max_mod_len = mod_len if mod_len > max_mod_len else max_mod_len 426 | cver_len = len(data["version"]) if data["installed"] else len("Version") 427 | max_cver_len = cver_len if cver_len > max_cver_len else max_cver_len 428 | lver_len = ( 429 | len(data["latest"]["version"]) if "latest" in data else len("Version") 430 | ) 431 | max_lver_len = lver_len if lver_len > max_lver_len else max_lver_len 432 | 433 | self.max_mod_len = max_mod_len 434 | self.max_cver_len = max_cver_len 435 | self.max_lver_len = max_lver_len 436 | self.max_ver_len = max_lver_len if max_lver_len > max_cver_len else max_cver_len 437 | 438 | def list(self): 439 | """Lists the mods installed on this server.""" 440 | # Find the longest mod name 441 | 442 | print( 443 | "{:<{width}}\tenabled\tinstalled\tcurrent_v\tlatest_v".format( 444 | "mod_name", width=self.max_mod_len 445 | ) 446 | ) 447 | for mod, data in self.mods.items(): 448 | print( 449 | "{:<{width}}\t{enbld}\t{inst}\t\t{cver}\t\t{lver}".format( 450 | mod, 451 | enbld=str(data["enabled"]), 452 | inst=str(data["installed"]), 453 | cver=data["version"] if data["installed"] else "N/A", 454 | lver=data["latest"]["version"] if "latest" in data else "N/A", 455 | width=self.max_mod_len, 456 | ) 457 | ) 458 | 459 | def override_credentials(self, username: str, token: str): 460 | """Replaces the values provided in server-settings.json or player-data.json""" 461 | if username is not None: 462 | self.username = username 463 | if token is not None: 464 | self.token = token 465 | 466 | def _print_mod_message( 467 | self, mod: str, version: str, action: str, result: str, message: str, data: hash 468 | ): 469 | """ 470 | Prints a mod status message using the provided parameters. 471 | """ 472 | if data is not None: 473 | title = data["title"] if self.title_mode else mod 474 | else: 475 | title = mod 476 | 477 | output_string = ( 478 | "{title:<{mwidth}}\t{version:<{vwidth}}" 479 | "\t{action:<10}\t{result:<10}\t{message}" 480 | ).format( 481 | title=title, 482 | version=version, 483 | action=action, 484 | result=result, 485 | message=message, 486 | vwidth=self.max_ver_len, 487 | mwidth=self.max_mod_len, 488 | ) 489 | print(output_string) 490 | 491 | def update(self): 492 | """ 493 | Updates all mods currently installed on this server to the latest 494 | release 495 | """ 496 | self._print_mod_message("Mod", "Version", "Action", "Result", "Message", None) 497 | 498 | for mod, data in self.mods.items(): 499 | version = data["version"] if data["installed"] else "N/A" 500 | if "metadata" not in data: 501 | self._print_mod_message( 502 | mod=mod, 503 | version=version, 504 | action="Skip", 505 | result="N/A", 506 | message="Missing metadata, skipping update!", 507 | data=data, 508 | ) 509 | continue 510 | if "latest" not in data: 511 | message = ( 512 | "No release found for factorio '{version}', skipping update!" 513 | ).format(version=self.fact_version["release"]) 514 | self._print_mod_message( 515 | mod=mod, 516 | version=version, 517 | action="Skip", 518 | result="N/A", 519 | message=message, 520 | data=data, 521 | ) 522 | continue 523 | 524 | self._prune_old_releases(mod) 525 | self._download_latest_release(mod) 526 | 527 | # Update the mod list file 528 | self._update_mod_list() 529 | 530 | def _prune_old_releases(self, mod: str): 531 | """ 532 | Deletes any locally installed versions older than the latest release. 533 | 534 | Keyword Arguments: 535 | mod -- name of the target to update 536 | """ 537 | data = self.mods[mod] 538 | latest_version = data["latest"]["version"] 539 | 540 | # Declare the patterns 541 | mod_pattern = re.compile( 542 | "^{mod}_({ver})[.]zip$".format(mod=mod, ver=self.MOD_VERSION_PATTERN) 543 | ) 544 | version_pattern = re.compile( 545 | "^{mod}_{ver}[.]zip$".format(mod=mod, ver=latest_version) 546 | ) 547 | 548 | # Build the parse list 549 | basenames = [os.path.basename(x) for x in self.mod_files] 550 | inst_rels = [x for x in basenames if mod_pattern.fullmatch(x)] 551 | for rel in inst_rels: 552 | if version_pattern.fullmatch(rel): 553 | continue 554 | 555 | match = mod_pattern.fullmatch(rel) 556 | if match: 557 | rel_ver = match.group(1) 558 | else: 559 | rel_ver = "TBD" 560 | 561 | rel_path = os.path.join(self.mod_path, rel) 562 | try: 563 | os.remove(rel_path) 564 | result = "Success" 565 | message = "" 566 | except OSError as error: 567 | message = ("error: failed to remove '{fname}': " "{errstr}").format( 568 | fname=rel_path, errstr=error.strerror 569 | ) 570 | result = "Failure" 571 | 572 | self._print_mod_message( 573 | mod=mod, 574 | version=rel_ver, 575 | action="Remove", 576 | result=result, 577 | message=message, 578 | data=data, 579 | ) 580 | 581 | def _download_latest_release(self, mod: str): 582 | """ 583 | Retrieves the latest version of the specified mod compatible with the 584 | factorio release present on this server. 585 | 586 | Keyword Arguments: 587 | mod -- name of the target to update 588 | """ 589 | data = self.mods[mod] 590 | latest = data["latest"] 591 | target = os.path.join(self.mod_path, latest["file_name"]) 592 | 593 | validate = download = False 594 | 595 | v_cur = data["version"] if "version" in data else "N/A" 596 | v_new = latest["version"] 597 | if data["installed"]: 598 | if v_new == v_cur: 599 | validate = True 600 | else: 601 | message = "Updating from '{v_cur}'".format(v_cur=v_cur) 602 | download = True 603 | else: 604 | message = "Downloading initial release '{v_new}'".format(v_new=v_new) 605 | download = True 606 | 607 | if validate: 608 | if _validate_hash(latest["sha1"], target): 609 | result = "Success" 610 | message = "Deprecated mod" if data["deprecated"] else "" 611 | else: 612 | result = "Failure" 613 | download = True 614 | message = "Validation failed, downloading again" 615 | self._print_mod_message( 616 | mod=mod, 617 | version=v_cur, 618 | action="Validate", 619 | result=result, 620 | message=message, 621 | data=data, 622 | ) 623 | 624 | if download: 625 | creds = {"username": self.username, "token": self.token} 626 | dl_url = self.mod_server_url + latest["download_url"] 627 | with requests.get(dl_url, params=creds, stream=True) as req: 628 | if req.status_code == 200: 629 | with open(target, "wb") as target_file: 630 | shutil.copyfileobj(req.raw, target_file) 631 | target_file.flush() 632 | if _validate_hash(latest["sha1"], target): 633 | result = "Success" 634 | else: 635 | result = "Failure" 636 | message = "Download did not match checksum!" 637 | elif req.status_code == 403: 638 | message = ( 639 | "Failed to download, credentials not accepted. " 640 | + "Check your username/token" 641 | ) 642 | result = "Failure" 643 | else: 644 | message = "Unable to retrieve, status code: " + str(req.status_code) 645 | result = "Failure" 646 | 647 | self._print_mod_message( 648 | mod=mod, 649 | version=v_new, 650 | action="Download", 651 | result=result, 652 | message=message, 653 | data=data, 654 | ) 655 | 656 | 657 | if __name__ == "__main__": 658 | DESC_TEXT = "Updates mods for a target factorio installation" 659 | PARSER = argparse.ArgumentParser(description=DESC_TEXT) 660 | # Username 661 | PARSER.add_argument( 662 | "-u", 663 | "--username", 664 | dest="username", 665 | help="factorio.com username overriding server-settings.json/player-data.json", 666 | ) 667 | # Token 668 | PARSER.add_argument( 669 | "-t", 670 | "--token", 671 | dest="token", 672 | help="factorio.com API token overriding server-settings.json/player-data.json", 673 | ) 674 | # Title format 675 | PARSER.add_argument( 676 | "--print-titles", 677 | dest="title_mode", 678 | default=False, 679 | action="store_true", 680 | help="When true, print the mod title instead of the api name", 681 | ) 682 | # Server Settings 683 | PARSER.add_argument( 684 | "-s", 685 | "--server-settings", 686 | dest="settings_path", 687 | required=False, 688 | help=( 689 | "Absolute path to the server-settings.json file " 690 | + "(overrides player-data.json)" 691 | ), 692 | ) 693 | # Player Data 694 | PARSER.add_argument( 695 | "-d", 696 | "--player-data", 697 | dest="data_path", 698 | required=False, 699 | help="Absolute path to the player-data.json file", 700 | ) 701 | # Factorio mod directory 702 | PARSER.add_argument( 703 | "-m", 704 | "--mod-directory", 705 | dest="mod_path", 706 | required=True, 707 | help="Absolute path to the mod directory", 708 | ) 709 | # Factorio binary absolute path 710 | PARSER.add_argument( 711 | "--fact-path", 712 | dest="fact_path", 713 | required=True, 714 | help="Absolute path to the factorio binary", 715 | ) 716 | # Possible Execution modes 717 | MODE_GROUP = PARSER.add_mutually_exclusive_group(required=True) 718 | MODE_GROUP.add_argument( 719 | "--list", 720 | dest="mode", 721 | action="store_const", 722 | const=ModUpdater.Mode.LIST, 723 | help="List the currently installed mods with versions", 724 | ) 725 | MODE_GROUP.add_argument( 726 | "--update", 727 | dest="mode", 728 | action="store_const", 729 | const=ModUpdater.Mode.UPDATE, 730 | help="Update all mods to their latest release", 731 | ) 732 | 733 | ARGS = PARSER.parse_args() 734 | UPDATER = ModUpdater( 735 | settings_path=ARGS.settings_path, 736 | data_path=ARGS.data_path, 737 | mod_path=ARGS.mod_path, 738 | fact_path=ARGS.fact_path, 739 | creds={"username": ARGS.username, "token": ARGS.token}, 740 | title_mode=ARGS.title_mode, 741 | ) 742 | 743 | if ARGS.mode == ModUpdater.Mode.LIST: 744 | UPDATER.list() 745 | elif ARGS.mode == ModUpdater.Mode.UPDATE: 746 | UPDATER.update() 747 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py36', 'py37', 'py38'] 3 | 4 | [tool.isort] 5 | multi_line_output = 3 6 | include_trailing_comma = true 7 | force_grid_wrap = 0 8 | use_parentheses = true 9 | ensure_newline_before_comments = true 10 | line_length = 88 11 | 12 | [tool.pylint.messages_control] 13 | disable = "C0330, C0326" 14 | 15 | [tool.pylint.format] 16 | max-line-length = "88" 17 | --------------------------------------------------------------------------------