# SPDX-License-Identifier: AGPL-3.0-or-later """Implementations for loading configurations from YAML files. This essentially includes the configuration of the (:ref:`SearXNG appl `) server. The default configuration for the application server is loaded from the :origin:`DEFAULT_SETTINGS_FILE `. This default configuration can be completely replaced or :ref:`customized individually ` and the ``SEARXNG_SETTINGS_PATH`` environment variable can be used to set the location from which the local customizations are to be loaded. The rules used for this can be found in the :py:obj:`get_user_cfg_folder` function. - By default, local configurations are expected in folder ``/etc/searxng`` from where applications can load them with the :py:obj:`get_yaml_cfg` function. - By default, customized :ref:`SearXNG appl ` settings are expected in a file named ``settings.yml``. """ from __future__ import annotations import os.path from collections.abc import Mapping from itertools import filterfalse from pathlib import Path import yaml from searx.exceptions import SearxSettingsException searx_dir = os.path.abspath(os.path.dirname(__file__)) SETTINGS_YAML = Path("settings.yml") DEFAULT_SETTINGS_FILE = Path(searx_dir) / SETTINGS_YAML """The :origin:`searx/settings.yml` file with all the default settings.""" def load_yaml(file_name: str | Path): """Load YAML config from a file.""" try: with open(file_name, 'r', encoding='utf-8') as settings_yaml: return yaml.safe_load(settings_yaml) or {} except IOError as e: raise SearxSettingsException(e, str(file_name)) from e except yaml.YAMLError as e: raise SearxSettingsException(e, str(file_name)) from e def get_yaml_cfg(file_name: str | Path) -> dict: """Shortcut to load a YAML config from a file, located in the - :py:obj:`get_user_cfg_folder` or - in the ``searx`` folder of the SearXNG installation """ folder = get_user_cfg_folder() or Path(searx_dir) fname = folder / file_name if not fname.is_file(): raise FileNotFoundError(f"File {fname} does not exist!") return load_yaml(fname) def get_user_cfg_folder() -> Path | None: """Returns folder where the local configurations are located. 1. If the ``SEARXNG_SETTINGS_PATH`` environment is set and points to a folder (e.g. ``/etc/mysxng/``), all local configurations are expected in this folder. The settings of the :ref:`SearXNG appl ` then expected in ``settings.yml`` (e.g. ``/etc/mysxng/settings.yml``). 2. If the ``SEARXNG_SETTINGS_PATH`` environment is set and points to a file (e.g. ``/etc/mysxng/myinstance.yml``), this file contains the settings of the :ref:`SearXNG appl ` and the folder (e.g. ``/etc/mysxng/``) is used for all other configurations. This type (``SEARXNG_SETTINGS_PATH`` points to a file) is suitable for use cases in which different profiles of the :ref:`SearXNG appl ` are to be managed, such as in test scenarios. 3. If folder ``/etc/searxng`` exists, it is used. In case none of the above path exists, ``None`` is returned. In case of environment ``SEARXNG_SETTINGS_PATH`` is set, but the (folder or file) does not exists, a :py:obj:`EnvironmentError` is raised. """ folder = None settings_path = os.environ.get("SEARXNG_SETTINGS_PATH") # Disable default /etc/searxng is intended exclusively for internal testing purposes # and is therefore not documented! disable_etc = os.environ.get('SEARXNG_DISABLE_ETC_SETTINGS', '').lower() in ('1', 'true') if settings_path: # rule 1. and 2. settings_path = Path(settings_path) if settings_path.is_dir(): folder = settings_path elif settings_path.is_file(): folder = settings_path.parent else: raise EnvironmentError(1, f"{settings_path} not exists!", settings_path) if not folder and not disable_etc: # default: rule 3. folder = Path("/etc/searxng") if not folder.is_dir(): folder = None return folder def update_dict(default_dict, user_dict): for k, v in user_dict.items(): if isinstance(v, Mapping): default_dict[k] = update_dict(default_dict.get(k, {}), v) else: default_dict[k] = v return default_dict def update_settings(default_settings: dict, user_settings: dict): # pylint: disable=too-many-branches # merge everything except the engines for k, v in user_settings.items(): if k not in ('use_default_settings', 'engines'): if k in default_settings and isinstance(v, Mapping): update_dict(default_settings[k], v) else: default_settings[k] = v categories_as_tabs = user_settings.get('categories_as_tabs') if categories_as_tabs: default_settings['categories_as_tabs'] = categories_as_tabs # parse the engines remove_engines = None keep_only_engines = None use_default_settings = user_settings.get('use_default_settings') if isinstance(use_default_settings, dict): remove_engines = use_default_settings.get('engines', {}).get('remove') keep_only_engines = use_default_settings.get('engines', {}).get('keep_only') if 'engines' in user_settings or remove_engines is not None or keep_only_engines is not None: engines = default_settings['engines'] # parse "use_default_settings.engines.remove" if remove_engines is not None: engines = list(filterfalse(lambda engine: (engine.get('name')) in remove_engines, engines)) # parse "use_default_settings.engines.keep_only" if keep_only_engines is not None: engines = list(filter(lambda engine: (engine.get('name')) in keep_only_engines, engines)) # parse "engines" user_engines = user_settings.get('engines') if user_engines: engines_dict = dict((definition['name'], definition) for definition in engines) for user_engine in user_engines: default_engine = engines_dict.get(user_engine['name']) if default_engine: update_dict(default_engine, user_engine) else: engines.append(user_engine) # store the result default_settings['engines'] = engines return default_settings def is_use_default_settings(user_settings): use_default_settings = user_settings.get('use_default_settings') if use_default_settings is True: return True if isinstance(use_default_settings, dict): return True if use_default_settings is False or use_default_settings is None: return False raise ValueError('Invalid value for use_default_settings') def load_settings(load_user_settings=True) -> tuple[dict, str]: """Function for loading the settings of the SearXNG application (:ref:`settings.yml `).""" msg = f"load the default settings from {DEFAULT_SETTINGS_FILE}" cfg = load_yaml(DEFAULT_SETTINGS_FILE) cfg_folder = get_user_cfg_folder() if not load_user_settings or not cfg_folder: return cfg, msg settings_yml = os.environ.get("SEARXNG_SETTINGS_PATH") if settings_yml and Path(settings_yml).is_file(): # see get_user_cfg_folder() --> SEARXNG_SETTINGS_PATH points to a file settings_yml = Path(settings_yml).name else: # see get_user_cfg_folder() --> SEARXNG_SETTINGS_PATH points to a folder settings_yml = SETTINGS_YAML cfg_file = cfg_folder / settings_yml if not cfg_file.exists(): return cfg, msg msg = f"load the user settings from {cfg_file}" user_cfg = load_yaml(cfg_file) if is_use_default_settings(user_cfg): # the user settings are merged with the default configuration msg = f"merge the default settings ( {DEFAULT_SETTINGS_FILE} ) and the user settings ( {cfg_file} )" update_settings(cfg, user_cfg) else: cfg = user_cfg return cfg, msg