"""Handle the game, runner and global system configurations.""" import os import time from shutil import copyfile from typing import Set from lutris import settings, sysoptions from lutris.runners import InvalidRunnerError, import_runner from lutris.util.log import logger from lutris.util.system import path_exists from lutris.util.yaml import read_yaml_from_file, write_yaml_to_file def make_game_config_id(game_slug: str) -> str: """Return an unique config id to avoid clashes between multiple games""" return "{}-{}".format(game_slug, int(time.time())) def write_game_config(game_slug: str, config: dict): """Writes a game config to disk""" configpath = make_game_config_id(game_slug) logger.debug("Writing game config to %s", configpath) config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % configpath) write_yaml_to_file(config, config_filename) return configpath def duplicate_game_config(game_slug: str, source_config_id: str): """Copies an existing configuration file, giving it a new id that this function returns.""" new_config_id = make_game_config_id(game_slug) src_path = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % source_config_id) dest_path = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % new_config_id) copyfile(src_path, dest_path) return new_config_id class LutrisConfig: """Class where all the configuration handling happens. Description =========== Lutris' configuration uses a cascading mechanism where each higher, more specific level overrides the lower ones The levels are (highest to lowest): `game`, `runner` and `system`. Each level has its own set of options (config section), available to and overridden by upper levels: ``` level | Config sections -------|---------------------- game | system, runner, game runner | system, runner system | system ``` Example: if requesting runner options at game level, their returned value will be from the game level config if it's set at this level; if not it will be the value from runner level if available; and if not, the default value set in the runner's module, or None. The config levels are stored in separate YAML format text files. Usage ===== The config level will be auto set depending on what you pass to __init__: - For game level, pass game_config_id and optionally runner_slug (better perfs) - For runner level, pass runner_slug - For system level, pass nothing If need be, you can pass the level manually. To read, use the config sections dicts: game_config, runner_config and system_config. To write, modify the relevant `raw_*_config` section dict, then run `save()`. """ def __init__( self, runner_slug: str = None, game_config_id: str = None, level: str = None, options_supported: Set[str] = None ): self.game_config_id = game_config_id if runner_slug: self.runner_slug = str(runner_slug) else: self.runner_slug = runner_slug self.options_supported = options_supported # Cascaded config sections (for reading) self.game_config = {} self.runner_config = {} self.system_config = {} # Raw (non-cascaded) sections (for writing) self.raw_game_config = {} self.raw_runner_config = {} self.raw_system_config = {} self.raw_config = {} # Set config level self.level = level if not level: if game_config_id: self.level = "game" elif runner_slug: self.level = "runner" else: self.level = "system" self.initialize_config() def __repr__(self): return "LutrisConfig(level=%s, game_config_id=%s, runner=%s)" % ( self.level, self.game_config_id, self.runner_slug, ) @property def system_config_path(self): return os.path.join(settings.CONFIG_DIR, "system.yml") @property def runner_config_path(self): if not self.runner_slug: return None return os.path.join(settings.RUNNERS_CONFIG_DIR, "%s.yml" % self.runner_slug) @property def game_config_path(self): if not self.game_config_id: return None return os.path.join(settings.CONFIG_DIR, "games/%s.yml" % self.game_config_id) def initialize_config(self): """Init and load config files""" self.game_level = {"system": {}, self.runner_slug: {}, "game": {}} self.runner_level = {"system": {}, self.runner_slug: {}} self.system_level = {"system": {}} self.game_level.update(read_yaml_from_file(self.game_config_path)) self.runner_level.update(read_yaml_from_file(self.runner_config_path)) self.system_level.update(read_yaml_from_file(self.system_config_path)) self.update_cascaded_config() self.update_raw_config() def update_cascaded_config(self): if self.system_level.get("system") is None: self.system_level["system"] = {} self.system_config.clear() self.system_config.update(self.get_defaults("system")) self.system_config.update(self.system_level.get("system")) if self.level in ["runner", "game"] and self.runner_slug: if self.runner_level.get(self.runner_slug) is None: self.runner_level[self.runner_slug] = {} if self.runner_level.get("system") is None: self.runner_level["system"] = {} self.runner_config.clear() self.runner_config.update(self.get_defaults("runner")) self.runner_config.update(self.runner_level.get(self.runner_slug)) self.merge_to_system_config(self.runner_level.get("system")) if self.level == "game" and self.runner_slug: if self.game_level.get("game") is None: self.game_level["game"] = {} if self.game_level.get(self.runner_slug) is None: self.game_level[self.runner_slug] = {} if self.game_level.get("system") is None: self.game_level["system"] = {} self.game_config.clear() self.game_config.update(self.get_defaults("game")) self.game_config.update(self.game_level.get("game")) self.runner_config.update(self.game_level.get(self.runner_slug)) self.merge_to_system_config(self.game_level.get("system")) def merge_to_system_config(self, config): """Merge a configuration to the system configuration""" if not config: return existing_env = None if self.system_config.get("env") and "env" in config: existing_env = self.system_config["env"] self.system_config.update(config) if existing_env: self.system_config["env"] = existing_env self.system_config["env"].update(config["env"]) def update_raw_config(self): # Select the right level of config if self.level == "game": raw_config = self.game_level elif self.level == "runner": raw_config = self.runner_level else: raw_config = self.system_level # Load config sections self.raw_system_config = raw_config["system"] if self.level in ["runner", "game"]: self.raw_runner_config = raw_config[self.runner_slug] if self.level == "game": self.raw_game_config = raw_config["game"] self.raw_config = raw_config def remove(self): """Delete the configuration file from disk.""" if not path_exists(self.game_config_path): logger.debug("No config file at %s", self.game_config_path) return os.remove(self.game_config_path) logger.debug("Removed config %s", self.game_config_path) def save(self): """Save configuration file according to its type""" if self.options_supported is not None: raise RuntimeError("LutrisConfig instances that are restricted to only some options can't be saved.") if self.level == "system": config = self.system_level config_path = self.system_config_path elif self.level == "runner": config = self.runner_level config_path = self.runner_config_path elif self.level == "game": config = self.game_level config_path = self.game_config_path else: raise ValueError("Invalid config level '%s'" % self.level) # Remove keys with no values from config before saving config = {key: value for key, value in config.items() if value} logger.debug("Saving %s config to %s", self, config_path) write_yaml_to_file(config, config_path) self.initialize_config() def get_defaults(self, options_type): """Return a dict of options' default value.""" options_dict = self.options_as_dict(options_type) defaults = {} for option, params in options_dict.items(): if "default" in params: default = params["default"] if callable(default): if self.options_supported is None or option in self.options_supported: try: default = default() except Exception as ex: logger.exception("Unable to generate a default for '%s': %s", option, ex) continue else: # Do not evaluate options we aren't supposed to use, in case # this is expensive or unsafe. default = None defaults[option] = default return defaults def options_as_dict(self, options_type: str) -> dict: """Convert the option list to a dict with option name as keys""" if options_type == "system": options = ( sysoptions.with_runner_overrides(self.runner_slug) if self.runner_slug else sysoptions.system_options ) else: if not self.runner_slug: return {} attribute_name = options_type + "_options" try: runner = import_runner(self.runner_slug) except InvalidRunnerError: options = {} else: if not getattr(runner, attribute_name): runner = runner() options = getattr(runner, attribute_name) return dict((opt["option"], opt) for opt in options)