# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2016-2017 Anaconda, Inc. # # May be copied and distributed freely only as part of an Anaconda or # Miniconda installation. # ----------------------------------------------------------------------------- """ This module provides user configuration file management features. It's based on the ConfigParser module (present in the standard library). """ from __future__ import print_function import ast import contextlib import io import os import os.path as osp import re import shutil import time from anaconda_navigator.config.base import get_home_dir from anaconda_navigator.utils.py3compat import configparser as cp from anaconda_navigator.utils.py3compat import is_text_string, to_text_string # ----------------------------------------------------------------------------- # --- Auxiliary classes # ----------------------------------------------------------------------------- class NoDefault: # pylint: disable=too-few-public-methods """NoDefault object.""" # ----------------------------------------------------------------------------- # --- Defaults class # ----------------------------------------------------------------------------- class DefaultsConfig(cp.ConfigParser): """Class used to save defaults to a file.""" def __init__(self, name, subfolder): """ Class used to save defaults to a file. Parameters ----------- name: str Name of the configuration. subfolder: str Path to folder location of configuration file. """ cp.ConfigParser.__init__(self) self.name = name self.subfolder = subfolder def _write(self, fp): # pylint: disable=invalid-name """Private write method for Python 2. The one from configparser fails for non-ascii Windows accounts. """ if self._defaults: fp.write(f'[{cp.DEFAULTSECT}]\n') for (key, value) in self._defaults.items(): write_value = str(value).replace('\n', '\n\t') fp.write(f'{key} = {write_value}\n') fp.write('\n') for section in self._sections: fp.write(f'[{section}]\n') for (key, value) in self._sections[section].items(): if key == '__name__': continue if (value is not None) or (self._optcre == self.OPTCRE): value = to_text_string(value) key = ' = '.join((key, value.replace('\n', '\n\t'))) fp.write(f'{key}\n') fp.write('\n') def _set(self, section, option, value, verbose): """Private set method.""" if not self.has_section(section): self.add_section(section) if not is_text_string(value): value = repr(value) if verbose: print(f'{section}[ {option} ] = {value}') cp.ConfigParser.set(self, section, option, value) def _save(self): """Save config into the associated .ini file.""" fname = self.filename() def _write_file(fname): with open(fname, 'w', encoding='utf-8') as configfile: self.write(configfile) try: # the "easy" way _write_file(fname) except IOError: try: # the "delete and sleep" way if osp.isfile(fname): os.remove(fname) time.sleep(0.05) _write_file(fname) except Exception: print('Failed to write user configuration file.') print('Please submit a bug report.') raise def filename(self): """Create a .ini filename located in user home directory.""" folder = osp.join(get_home_dir(), self.subfolder) # Save defaults in a "defaults" dir of .anaconda_navigator to not # pollute it if 'defaults' in self.name: folder = osp.join(folder, 'defaults') try: os.makedirs(folder) except os.error: # Folder (or one of its parents) already exists pass ini_file = osp.join(folder, f'{self.name}.ini') return ini_file def set_defaults(self, defaults): """Set default configuration.""" for section, options in defaults: for option in options: new_value = options[option] self._set(section, option, new_value, False) # ----------------------------------------------------------------------------- # --- User config class # ----------------------------------------------------------------------------- class UserConfig(DefaultsConfig): """ User configuration class. Note that 'get' and 'set' arguments number and type differ from the overriden methods. """ DEFAULT_SECTION_NAME = 'main' # pylint: disable=invalid-name def __init__( # pylint: disable=too-many-arguments self, name, defaults=None, load=True, version=None, subfolder=None, backup=False, raw_mode=False, remove_obsolete=False ): """ User configuration class. Parameters ---------- name: str Name of the configuration. defaults: dict dictionnary containing options *or* list of tuples (section_name, options). version: str Version of the configuration file (X.Y.Z format). subfolder: str Configuration file will be saved in HOME/subfolder/name.ini. """ DefaultsConfig.__init__(self, name, subfolder) self.raw = 1 if raw_mode else 0 if (version is not None) and (re.match(r'^(\d+).(\d+).(\d+)$', version) is None): raise ValueError(f'Version number {version} is incorrect - must be in X.Y.Z format') if isinstance(defaults, dict): defaults = [(self.DEFAULT_SECTION_NAME, defaults)] self.defaults = defaults if defaults is not None: self.reset_to_defaults(save=False) fname = self.filename() if backup: try: shutil.copyfile(fname, f'{fname}.bak') except IOError: pass if load: # If config file already exists, it overrides Default options: self.load_from_ini() old_ver = self.get_version(version) # Save new defaults self.__save_new_defaults(defaults, version, subfolder) # Updating defaults only if major/minor version is different if self._minor(version) != self._minor(old_ver): if backup: try: shutil.copyfile(fname, f'{fname}-{old_ver}.bak') except IOError: pass self.__update_defaults(defaults, old_ver) # Remove deprecated options if major version has changed if (remove_obsolete or self._major(version) != self._major(old_ver)): self.__remove_deprecated_options(old_ver) # Set new version number self.set_version(version, save=False) if defaults is None: # If no defaults are defined, set .ini file settings as default self.set_as_defaults() @staticmethod def _major(_t): """Return major component in config versions.""" return _t[:_t.find('.')] @staticmethod def _minor(_t): """Return minor component in config versions.""" return _t[:_t.rfind('.')] def get_version(self, version='0.0.0'): """Return configuration (not application!) version.""" return self.get(self.DEFAULT_SECTION_NAME, 'version', version) def set_version(self, version='0.0.0', save=True): """Set configuration (not application!) version.""" self.set(self.DEFAULT_SECTION_NAME, 'version', version, save=save) def set_logged_data(self, url=None, brand=None): """Set `logged_api_url` and `logged_brand` config options.""" self.set(self.DEFAULT_SECTION_NAME, 'logged_api_url', url) self.set(self.DEFAULT_SECTION_NAME, 'logged_brand', brand) def get_logged_data(self): """Set `logged_brand` and `logged_api_url` config options.""" return self.get(self.DEFAULT_SECTION_NAME, 'logged_brand',),\ self.get(self.DEFAULT_SECTION_NAME, 'logged_api_url') def is_logged_brand(self, url=None, brand=None): """Check if given `url` or `brand` match current repo.""" logged_brand, logged_url = self.get_logged_data() if brand and logged_brand: return brand == logged_brand if logged_url and url: return url == logged_url return False def load_from_ini(self): """Load config from the associated .ini file.""" try: self.read(self.filename(), encoding='utf-8') except cp.MissingSectionHeaderError: print('Warning: File contains no section headers.') def __load_old_defaults(self, old_version): """Read old defaults.""" old_defaults = cp.ConfigParser() path = osp.dirname(self.filename()) path = osp.join(path, 'defaults') old_defaults.read(osp.join(path, 'defaults-' + old_version + '.ini')) return old_defaults @staticmethod def __save_new_defaults(defaults, new_version, subfolder): """Save new defaults.""" new_defaults = DefaultsConfig( name='defaults-' + new_version, subfolder=subfolder, ) if not osp.isfile(new_defaults.filename()): new_defaults.set_defaults(defaults) new_defaults._save() # pylint: disable=protected-access def __update_defaults(self, defaults, old_version, verbose=False): """Update defaults after a change in version.""" old_defaults = self.__load_old_defaults(old_version) for section, options in defaults: for option in options: new_value = options[option] try: old_value = old_defaults.get(section, option) except (cp.NoSectionError, cp.NoOptionError): old_value = None if (old_value is None or to_text_string(new_value) != old_value): self._set(section, option, new_value, verbose) def __remove_deprecated_options(self, old_version): """Remove options present in the .ini file but not in defaults.""" old_defaults = self.__load_old_defaults(old_version) for section in old_defaults.sections(): for option, _ in old_defaults.items(section, raw=self.raw): if self.get_default(section, option) is NoDefault: self.remove_option(section, option) if len(self.items(section, raw=self.raw)) == 0: self.remove_section(section) def cleanup(self): """Remove .ini file associated to config.""" os.remove(self.filename()) def set_as_defaults(self): """Set defaults from the current config.""" self.defaults = [] for section in self.sections(): secdict = {} for option, value in self.items(section, raw=self.raw): secdict[option] = value self.defaults.append((section, secdict)) def get_defaults(self): # pylint: disable=missing-function-docstring self.reset_to_defaults(save=False) with io.StringIO() as fd: # pylint: disable=invalid-name self.write(fd) fd.seek(0) return fd.read() def reset_to_defaults(self, save=True, verbose=False, section=None): """Reset config to Default values.""" for sec, options in self.defaults: if section is None or section == sec: for option in options: value = options[option] self._set(sec, option, value, verbose) if save: self._save() def __check_section_option(self, section, option): """Private method to check section and option types.""" if section is None: section = self.DEFAULT_SECTION_NAME elif not is_text_string(section): raise RuntimeError("Argument 'section' must be a string") if not is_text_string(option): raise RuntimeError("Argument 'option' must be a string") return section def get_default(self, section, option): """ Get Default value for a given (section, option). -> useful for type checking in 'get' method """ section = self.__check_section_option(section, option) for sec, options in self.defaults: if sec == section and option in options: return options[option] return NoDefault def get(self, section, option, default=NoDefault): # pylint: disable=arguments-differ """ Get an option. section=None: attribute a default section name default: default value (if not specified, an exception will be raised if option doesn't exist) """ section = self.__check_section_option(section, option) if not self.has_section(section): if default is NoDefault: raise cp.NoSectionError(section) self.add_section(section) if not self.has_option(section, option): if default is NoDefault: raise cp.NoOptionError(option, section) self.set(section, option, default) return default value = cp.ConfigParser.get(self, section, option, raw=self.raw) default_value = self.get_default(section, option) if isinstance(default_value, bool): value = ast.literal_eval(value) elif isinstance(default_value, float): value = float(value) elif isinstance(default_value, int): value = int(value) else: with contextlib.suppress(BaseException): # lists, tuples, ... value = ast.literal_eval(value) return value def set_default(self, section, option, default_value): """ Set Default value for a given (section, option). -> called when a new (section, option) is set and no default exists """ section = self.__check_section_option(section, option) for sec, options in self.defaults: if sec == section: options[option] = default_value def set( # pylint: disable=arguments-differ,too-many-arguments self, section, option, value, verbose=False, save=True, ): """ Set an option. section=None: attribute a default section name """ section = self.__check_section_option(section, option) default_value = self.get_default(section, option) if default_value is NoDefault: default_value = value self.set_default(section, option, default_value) if isinstance(default_value, bool): value = bool(value) elif isinstance(default_value, float): value = float(value) elif isinstance(default_value, int): value = int(value) elif not is_text_string(default_value): value = repr(value) self._set(section, option, value, verbose) if save: self._save() def remove_section(self, section): """Remove the specified section from the configuration.""" cp.ConfigParser.remove_section(self, section) self._save() def remove_option(self, section, option): """Remove the specified option from the specified section.""" cp.ConfigParser.remove_option(self, section, option) self._save()