import configparser import itertools import logging import os import pathlib import re from collections.abc import Mapping from mopidy.config import keyring from mopidy.config.schemas import ConfigSchema, MapConfigSchema from mopidy.config.types import ( Boolean, ConfigValue, Deprecated, DeprecatedValue, Hostname, Integer, List, LogColor, LogLevel, Path, Port, Secret, String, ) from mopidy.internal import path, versioning __all__ = [ # TODO List everything that is reexported, not just the unused parts. "ConfigValue", "List", ] logger = logging.getLogger(__name__) _core_schema = ConfigSchema("core") _core_schema["cache_dir"] = Path() _core_schema["config_dir"] = Path() _core_schema["data_dir"] = Path() # MPD supports at most 10k tracks, some clients segfault when this is exceeded. _core_schema["max_tracklist_length"] = Integer(minimum=1) _core_schema["restore_state"] = Boolean(optional=True) _logging_schema = ConfigSchema("logging") _logging_schema["verbosity"] = Integer(minimum=-1, maximum=4) _logging_schema["format"] = String() _logging_schema["color"] = Boolean() _logging_schema["console_format"] = Deprecated() _logging_schema["debug_format"] = Deprecated() _logging_schema["debug_file"] = Deprecated() _logging_schema["config_file"] = Path(optional=True) _loglevels_schema = MapConfigSchema("loglevels", LogLevel()) _logcolors_schema = MapConfigSchema("logcolors", LogColor()) _audio_schema = ConfigSchema("audio") _audio_schema["mixer"] = String() _audio_schema["mixer_track"] = Deprecated() _audio_schema["mixer_volume"] = Integer(optional=True, minimum=0, maximum=100) _audio_schema["output"] = String() _audio_schema["visualizer"] = Deprecated() _audio_schema["buffer_time"] = Integer(optional=True, minimum=1) _proxy_schema = ConfigSchema("proxy") _proxy_schema["scheme"] = String( optional=True, choices=["http", "https", "socks4", "socks5"] ) _proxy_schema["hostname"] = Hostname(optional=True) _proxy_schema["port"] = Port(optional=True) _proxy_schema["username"] = String(optional=True) _proxy_schema["password"] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() _schemas = [ _core_schema, _logging_schema, _loglevels_schema, _logcolors_schema, _audio_schema, _proxy_schema, ] _INITIAL_HELP = """ # For further information about options in this file see: # https://docs.mopidy.com/ # # The initial commented out values reflect the defaults as of: # {versions} # # Available options and defaults might have changed since then, # run `mopidy config` to see the current effective config and # `mopidy --version` to check the current version. """ def read(config_file): """Helper to load config defaults in same way across core and extensions""" return pathlib.Path(config_file).read_text(errors="surrogateescape") def load(files, ext_schemas, ext_defaults, overrides): config_dir = pathlib.Path(__file__).parent defaults = [read(config_dir / "default.conf")] defaults.extend(ext_defaults) raw_config = _load(files, defaults, keyring.fetch() + (overrides or [])) schemas = _schemas[:] schemas.extend(ext_schemas) return _validate(raw_config, schemas) def format(config, ext_schemas, comments=None, display=True): schemas = _schemas[:] schemas.extend(ext_schemas) return _format(config, comments or {}, schemas, display, False) def format_initial(extensions_data): config_dir = pathlib.Path(__file__).parent defaults = [read(config_dir / "default.conf")] defaults.extend(d.extension.get_default_config() for d in extensions_data) raw_config = _load([], defaults, []) schemas = _schemas[:] schemas.extend(d.extension.get_config_schema() for d in extensions_data) config, errors = _validate(raw_config, schemas) versions = [f"Mopidy {versioning.get_version()}"] extensions_data = sorted( extensions_data, key=lambda d: d.extension.dist_name ) for data in extensions_data: versions.append(f"{data.extension.dist_name} {data.extension.version}") header = _INITIAL_HELP.strip().format(versions="\n# ".join(versions)) formatted_config = _format( config=config, comments={}, schemas=schemas, display=False, disable=True ) return header + "\n\n" + formatted_config def _load(files, defaults, overrides): parser = configparser.RawConfigParser() # TODO: simply return path to config file for defaults so we can load it # all in the same way? logger.info("Loading config from builtin defaults") for default in defaults: if isinstance(default, bytes): default = default.decode() parser.read_string(default) # Load config from a series of config files for f in files: f = path.expand_path(f) if f.is_dir(): for g in f.iterdir(): if g.is_file() and g.suffix == ".conf": _load_file(parser, g.resolve()) else: _load_file(parser, f.resolve()) raw_config = {} for section in parser.sections(): raw_config[section] = dict(parser.items(section)) logger.info("Loading config from command line options") for section, key, value in overrides: raw_config.setdefault(section, {})[key] = value return raw_config def _load_file(parser, file_path): if not file_path.exists(): logger.debug( f"Loading config from {file_path.as_uri()} failed; " f"it does not exist" ) return if not os.access(str(file_path), os.R_OK): logger.warning( f"Loading config from file_path.as_uri() failed; " f"read permission missing" ) return try: logger.info(f"Loading config from {file_path.as_uri()}") with file_path.open("r") as fh: parser.read_file(fh) except configparser.MissingSectionHeaderError: logger.warning( f"Loading config from {file_path.as_uri()} failed; " f"it does not have a config section" ) except configparser.ParsingError as e: linenos = ", ".join(str(lineno) for lineno, line in e.errors) logger.warning( f"Config file {file_path.as_uri()} has errors; " f"line {linenos} has been ignored" ) except OSError: # TODO: if this is the initial load of logging config we might not # have a logger at this point, we might want to handle this better. logger.debug(f"Config file {file_path.as_uri()} not found; skipping") def _validate(raw_config, schemas): # Get validated config config = {} errors = {} sections = set(raw_config) for schema in schemas: sections.discard(schema.name) values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) if error: errors[schema.name] = error if result: config[schema.name] = result for section in sections: logger.debug(f"Ignoring unknown config section: {section}") return config, errors def _format(config, comments, schemas, display, disable): output = [] for schema in schemas: serialized = schema.serialize( config.get(schema.name, {}), display=display ) if not serialized: continue output.append(f"[{schema.name}]") for key, value in serialized.items(): if isinstance(value, DeprecatedValue): continue comment = comments.get(schema.name, {}).get(key, "") output.append(f"{key} =") if value is not None: output[-1] += " " + value if comment: output[-1] += " ; " + comment.capitalize() if disable: output[-1] = re.sub(r"^", "#", output[-1], flags=re.M) output.append("") return "\n".join(output).strip() def _preprocess(config_string): """Convert a raw config into a form that preserves comments etc.""" results = ["[__COMMENTS__]"] counter = itertools.count(0) section_re = re.compile(r"^(\[[^\]]+\])\s*(.+)$") blank_line_re = re.compile(r"^\s*$") comment_re = re.compile(r"^(#|;)") inline_comment_re = re.compile(r" ;") def newlines(match): return f"__BLANK{next(counter):d}__ =" def comments(match): if match.group(1) == "#": return f"__HASH{next(counter):d}__ =" elif match.group(1) == ";": return f"__SEMICOLON{next(counter):d}__ =" def inlinecomments(match): return f"\n__INLINE{next(counter):d}__ =" def sections(match): return ( f"{match.group(1)}\n__SECTION{next(counter):d}__ = {match.group(2)}" ) for line in config_string.splitlines(): line = blank_line_re.sub(newlines, line) line = section_re.sub(sections, line) line = comment_re.sub(comments, line) line = inline_comment_re.sub(inlinecomments, line) results.append(line) return "\n".join(results) def _postprocess(config_string): """Converts a preprocessed config back to original form.""" flags = re.IGNORECASE | re.MULTILINE result = re.sub(r"^\[__COMMENTS__\](\n|$)", "", config_string, flags=flags) result = re.sub(r"\n__INLINE\d+__ =(.*)$", r" ;\g<1>", result, flags=flags) result = re.sub(r"^__HASH\d+__ =(.*)$", r"#\g<1>", result, flags=flags) result = re.sub(r"^__SEMICOLON\d+__ =(.*)$", r";\g<1>", result, flags=flags) result = re.sub(r"\n__SECTION\d+__ =(.*)$", r"\g<1>", result, flags=flags) result = re.sub(r"^__BLANK\d+__ =$", "", result, flags=flags) return result class Proxy(Mapping): def __init__(self, data): self._data = data def __getitem__(self, key): item = self._data.__getitem__(key) if isinstance(item, dict): return Proxy(item) return item def __iter__(self): return self._data.__iter__() def __len__(self): return self._data.__len__() def __repr__(self): return f"Proxy({self._data!r})"