def handle_file_error(err, func, path, args=None, kwargs=None,
                      pos_path=0, key_path=None, change_path_func=save_file,
                      title='', msg='', return_if_ignore=None):
    """If PermissionError when opening/saving file, propose to retry, change file path or cancel

    :param err: exception
    :param func: function to execute if the user wants to retry
    :param path: file path
    :param args: args to pass to func
    :param kwargs: kwargs to pass to func
    :param pos_path: position of the positional argument path in func (only if key_path is None)
    :param key_path: name of the keyword argument path in func (if None, positional argument is used)
    :param change_path_func: function to get a new path, with no positional argument and 'initialdir' keyword argument
    :param title: title of the error
    :param msg: message of the error
    :param return_if_ignore: return if Ignore option is selected
    :return:
    """
    logger.debug(err)
    args = args or []
    kwargs = kwargs or {}
    title = title or 'File error!'
    msg = msg or "Unknown error with file '{}'. \nOriginal error: {}".format(path, err)
    logger.warning('User action needed!')
    res = messagebox.askcustomquestion(title=title, message=msg,
                                       choices=["Retry", "Rename automatically", "Change file path",
                                                "Ignore", "Debug (developer only)", "Cancel"])
    if res == "Retry":
        if key_path is not None:
            kwargs[key_path] = path
        else:
            args.insert(pos_path, path)
        return func(*args, **kwargs)
    if res == "Rename automatically":
        n_path = _handle_existing_file_conflict(path=path, overwrite='rename')
        if key_path is not None:
            kwargs[key_path] = n_path
        else:
            args.insert(pos_path, n_path)
        return func(*args, **kwargs)
    elif res == "Change file path":
        initialdir = Path(path).dirname if Path(path).dirname.exists else None
        if key_path is not None:
            kwargs[key_path] = change_path_func(initialdir=initialdir)
        else:
            args.insert(pos_path, change_path_func(initialdir=initialdir))
        return func(*args, **kwargs)
    elif res == "Ignore":
        logger.warning("Function ignored!")
        logger.debug("Function '{}' with path '{}' ignored!")
        return return_if_ignore
    elif res == "Debug (developer only)":
        pdb.set_trace()
    elif res in [None, "Cancel"]:
        err = UnknownError if not isinstance(err, BaseException) else err
        logger.exception(err)
        raise err.__class__(err)
    else:
        raise TypeError("Bad return of function 'messagebox.askcustomquestion': '{}'".format(res))
Example #2
0
 def load(self, path=None, force_load=None, auto_cast=None, load_empty=None, merge_how='right'):
     """Load configuration from file.
     If force-load, reload_default values on error.."""
     path = self._path if path is None else Path(path)
     auto_cast = self._auto_cast if auto_cast is None else auto_cast
     force_load = self._force_load if force_load is None else force_load
     load_empty = self._load_empty if load_empty is None else load_empty
     if not path.isfile:
         if force_load:
             self.reload_default()
         return None
     config_dict = self.read_config(path, auto_cast=auto_cast)
     if config_dict is None:
         if force_load:
             self.reload_default()
         return None
     elif path.isfile:  # Change path
         self._path = path
         logger.debug("Path changed to '{}' with 'load' method.".format(self.path))
     elif config_dict.isempty:
         if not load_empty:
             logger.info("Configuration loaded is empty! It won't replace the existing one.")
             logger.debug("Use 'clear' method to empty the ConfigDict")
             return None
     else:
         raise UnknownError("bad case in 'load'")
     # config_dict.section = self.section
     # config_dict.default_section = self.default_section
     # config_dict._conversion_dict = self._conversion_dict
     self.merge(config_dict, how=merge_how, inplace=True)
     logger.info("Configuration loaded!")
Example #3
0
def save_excel_file(path, dataframes, sheet_names=None, extension=".xlsx",
                    check_path=False, to_excel_kwargs=None, **options):
    """Write dataframes to disk.

    :param path: Excel file path  (to be checked before with 'save_file' function).
    :param dataframes: dataframe or list of dataframes
    :param sheet_names:  sheet name or ordered list of sheet names
    :param extension: Excel file extension
    :param check_path: check path with save_file
    :param to_excel_kwargs: keyword arguments for _write_excel function
    :param options: options for save_file function
    :return: path
    """
    if check_path:
        path = save_file(path, extension=extension, **options)
    if isinstance(dataframes, pd.DataFrame):
        dataframes = [dataframes]
    length = len(dataframes)
    if sheet_names is None:
        sheet_names = ["Sheet{}".format(i + 1) for i in range(length)]
    elif isinstance(sheet_names, str):
        sheet_names = [sheet_names]
    if len(sheet_names) != length:
        logger.warning("Invalid sheet names! Default sheet names will be used.")
    to_excel_kwargs = {} if to_excel_kwargs is None else to_excel_kwargs
    n_path = _write_excel(Path(path), dataframes, sheet_names, **to_excel_kwargs)
    return n_path
def handle_file_not_found_error(err, func, path, args=None, kwargs=None,
                                pos_path=0, key_path=None, change_path_func=save_file,
                                title='', msg=''):
    title = title or 'File not found!'
    msg = msg or "File '{}' can not be found.\n" \
                 "Original error: {}".format(path, Path(path).ext, err)
    return handle_file_error(err, func, path, args=args, kwargs=kwargs,
                             pos_path=pos_path, key_path=key_path, change_path_func=change_path_func,
                             title=title, msg=msg)
def _set_writable(path: str):
    """Make a file writable if it exists and is read-only."""
    if Path(path).isfile and not os.access(path, os.W_OK):
        try:
            os.chmod(path, stat.S_IWUSR | stat.S_IREAD)  # if read-only existing file, make it writable
        except PermissionError:
            logger.debug("Failed to change file properties of '{}'".format(path))
            return
        logger.debug("File '{}' is now writable.")
Example #6
0
def choose_filedialog(dialog_type: str,
                      multiple_paths: bool = False,
                      return_on_cancellation: str = None,
                      behavior_on_cancellation: str = 'ignore',
                      initialdir: str = None,
                      filetypes: list = None,
                      title: str = None,
                      **kwargs) -> Union[tuple, Path]:
    """Open a filedialog window."""
    # Check dialog type.
    if dialog_type == 'save':
        user_input_func = filedialog.asksaveasfilename
    elif dialog_type == 'open':
        if multiple_paths:  # selection of multiple files
            user_input_func = filedialog.askopenfilenames
        else:  # selection of a unique file
            user_input_func = filedialog.askopenfilename
    elif dialog_type == 'open_dir':
        user_input_func = filedialog.askdirectory
    else:
        msg = "Argument 'dialog_type' must be 'save, 'open' or 'open_dir'."
        logger.error(msg)
        raise ValueError(msg)

    # Check inputs.
    if filetypes is None or not isinstance_filetypes(filetypes):
        filetypes = [("all files", "*.*")]
    if not isinstance(initialdir, str) or not Path(initialdir).isdir:
        initialdir = None
    if not isinstance(title, str):
        title = None

    # Get filenames.
    logger.warning('User action needed!')
    filetypes_kwargs = {} if dialog_type == 'open_dir' else dict(
        filetypes=filetypes)
    path = user_input_func(title=title,
                           initialdir=initialdir,
                           **filetypes_kwargs)
    if not path:  # if no file selected
        # raise an anomaly with flag behavior_on_cancellation ('ask', 'ignore', 'warning' or 'error').
        raise_no_file_selected_anomaly(flag=behavior_on_cancellation)
        return Path(return_on_cancellation)
    return Path(path)
def handle_bad_extension_error(err, func, path, args=None, kwargs=None,
                               pos_path=0, key_path=None, change_path_func=save_file,
                               title='', msg='', extension=None):
    """
    extension: default extension to add to the path. If None, nothing is changed
    """
    title = title or 'Bad extension error!'
    msg = msg or "File '{}' has an unsupported extension '{}'. \nOriginal error: {}".format(path, Path(path).ext, err)
    path = Path(path).join_ext(extension)
    return handle_file_error(err, func, path, args=args, kwargs=kwargs,
                             pos_path=pos_path, key_path=key_path, change_path_func=change_path_func,
                             title=title, msg=msg)
Example #8
0
    def __init__(self, *args, **kwargs):
        """Initialisation of a _Config instance.

        :param args:  see 'init' method
        :param kwargs: see 'init' method
        """
        super().__init__()
        self._cfg = ConfigDict()  # current configuration
        self._default_config = ConfigDict()  # default configuration
        self._temp_config = OrderedDict()  # temporary configuration
        self._path = Path()  # current configuration path
        self._default_path = Path()  # default configuration path
        self._conversion_dict = None
        self._auto_cast = None
        self._write_flags = None
        self._force_load = None
        self._load_empty = None
        self._ask_path = None
        self._search_in_default_config = None
        self._init_count = 0
        self._policies = defaultdict(bool)  # by default every modification is forbidden  # WIP
        if args or kwargs:
            self.init(*args, **kwargs)
        logger.debug("Config object created.")
Example #9
0
def add_file_extension(path: Union[str, Path],
                       extension=None,
                       replace=False,
                       keep_existing=False,
                       force_add=False) -> Path:
    """Add a specific extension to 'path'.

    :param path: path
    :param extension: extension. None is considered as '' extension
    :param replace: replace instead of append extension
    :param keep_existing: add extension only if it doesn't already exist
    :param force_add: add extension even if the correct extension already exists (not recommended)
    :return Path object
    """
    path = Path(path)
    if path.ext and keep_existing:
        if path.ext != FileExt(extension):
            logger.warning("Bad extension kept: {}".format(path.ext))
        return path
    if replace:
        return path.replace_ext(extension)
    if path.ext == FileExt(extension) and not force_add:
        return path
    return path.join_ext(extension)
Example #10
0
def open_file_or_dir(path: Union[str, list, tuple, set] = None, config_dict=None, config_key=None,
                     multiple_paths=False, return_on_cancellation: str = None,
                     behavior_on_cancellation='warning', filetype=None, extension=None,
                     check_ext='ignore', ask_path=True, path_type='file', **kwargs) -> Union[Path, PathCollection]:
    """Check path and open file dialogs if needed.

    :param path: path to check. If path is None, a file dialog is opened to choose path, unless 'ask_path' is False
    :param config_dict: instead of path, use config_dict and config_key. path will be set to config_dict[config_key]
    :param config_key: instead of path, use config_dict and config_key. path will be set to config_dict[config_key]
    :param multiple_paths: if True, multiple files are allowed and returned as PathCollection (instead of Path)
    :param return_on_cancellation: return on user cancellation. Default: Path(None) (highly recommended)
    :param behavior_on_cancellation: error flag used to raise anomaly 'raise_no_file_selected_anomaly' on cancellation .
                                     Must be 'ask', 'ignore', 'warning', or 'error'.
    :param filetype: filetype in FILETYPE_TO_FILEDIALOG_FILETYPES keys. If filetype is None, extension is not checked.
    :param extension: final extension to check. If extension is None, extension is not checked.
    :param check_ext: error flag used to raise anomaly 'raise_bad_extension_anomaly'
    :param ask_path: if True and path is None, ask path to the user
    :param path_type: 'file' or 'dir'. 'file by default
    :param kwargs: keywords arguments for filedialog methods, such as 'title', 'message', 'filetypes', etc.
    :return: path or collection of paths
    """
    # Check of input arguments path_type, path, config_dict and config_key
    path = check_path_arguments(path, config_dict, config_key)
    if path_type not in {'file', 'dir', None}:
        msg = "path_type '{}' is not valid".format(path_type)
        logger.error(msg)
        raise ValueError(msg)
    is_path = 'isdir' if path_type == 'dir' else 'isfile'
    path_name = "directory" if path_type == 'dir' else "file"
    path_names = "directories" if path_type == 'dir' else "files"

    # If path is an iterable, explore it recursively.
    if isiterable(path) and multiple_paths:
        result = PathCollection()
        for r_path in path:
            result.append(open_file_or_dir(path=r_path,
                                           multiple_paths=False,
                                           return_on_cancellation=return_on_cancellation,
                                           behavior_on_cancellation=behavior_on_cancellation,
                                           filetype=filetype, extension=extension,
                                           check_ext=check_ext, ask_path=ask_path, path_type=path_type, **kwargs))
        return result

    # Invalid type for path.
    if path is not None and not isinstance(path, str):
        messagebox.showwarning(title='Python type error!',
                               message="Path passed to the function is of type '{}', not str.\n\n"
                                       "N.B.: to add multiple {}, 'multiple_paths' argument must be True.\n\n"
                                       "Path has to be set manually.".format(path_names, type(path)))
        path = None

    # Convert path to type Path
    path = Path(path)
    if path.isnone and not ask_path:  # If path and ask_path are both None, pass
        pass
    elif not getattr(path, is_path):  # If path is not a file/dir path (includes None path), open a file dialog.
        if not path.isnone:  # Shows a warning if wrong path (do not include None path)
            messagebox.showwarning(title='Error while trying to find the {}!'.format(path_name),
                                   message="The path '{}' doesn't correspond to any {}. "
                                           "Path has to be set manually.".format(path, path_name))
        if 'filetypes' not in kwargs and path_type == 'file':
            kwargs['filetypes'] = FILETYPE_TO_FILEDIALOG_FILETYPES[filetype]
        if 'title' not in kwargs:
            kwargs['title'] = "Open {}".format(path_names if multiple_paths else path_name) if path.isnone \
                else "Open '{}' {}".format(path.filename, path_name)

        path = _filedialog_open(path_type, multiple_paths=multiple_paths, return_on_cancellation=return_on_cancellation,
                                behavior_on_cancellation=behavior_on_cancellation, **kwargs)

    # Check extension
    if (isiterable(path) or not path.isnone) and path_type == 'file':
        chk_ext = check_extension(path, extension=extension, filetype=filetype, check_ext=check_ext)
        if not chk_ext:
            return open_file_or_dir(path=None,
                                    multiple_paths=multiple_paths,
                                    return_on_cancellation=return_on_cancellation,
                                    behavior_on_cancellation=behavior_on_cancellation,
                                    filetype=filetype, extension=extension,
                                    check_ext=check_ext, ask_path=ask_path, path_type=path_type, **kwargs)
    if config_dict:  # save the new path in config dict
        config_dict[config_key] = path
    return path
Example #11
0
def save_file(path: Union[Path, str] = None, config_dict=None, config_key=None,
              return_on_cancellation=Path(None), behavior_on_cancellation='warning',
              auto_mkdir=True, extension=None, replace_ext=False, filetype=None,
              overwrite='ask', backup=False, **kwargs) -> Path:
    """Check the path to save a file.

    :param path: path or None
    :param config_dict: dictionary-like object containing the configuration
    :param config_key: key of config_dict to get path (replaces path argument)
   :param return_on_cancellation: return on user cancellation. Default: Path(None) (highly recommended)
    :param behavior_on_cancellation: error flag used to raise anomaly 'raise_no_file_selected_anomaly' on cancellation .
                                     Must be 'ask', 'ignore', 'warning', or 'error'.
    :param auto_mkdir: if True, try to create output directories if they don't exist
    :param filetype: filetype in FILETYPE_TO_FILEDIALOG_FILETYPES keys. If filetype is None, extension is not checked.
    :param extension: final extension to check. If extension is None, extension is not checked.
    :param replace_ext: if True, replaces extension instead of adding one
    :param overwrite: bool or flag 'ask', 'rename', 'ignore', 'overwrite', used in case file already exists
    :param backup: bool, used in case file already exists
    :param kwargs: for filedialog
    :return: path
    """
    # Check of input arguments.
    path = Path(check_path_arguments(path, config_dict, config_key))
    if 'filetypes' not in kwargs:
        kwargs.update({'filetypes': FILETYPE_TO_FILEDIALOG_FILETYPES[filetype]})
    # Ask for a path if path is None.
    if path.isnone:
        path = _filedialog_save(return_on_cancellation=return_on_cancellation,
                                behavior_on_cancellation=behavior_on_cancellation, **kwargs)

    # Check type of path.
    if not isinstance(path, str):
        messagebox.showwarning(title='Python type error!',
                               message="The path passed to the function is not a string."
                                       "Consider to take a look at the Python code! "
                                       "Path has to be set manually.")
        path = _filedialog_save(return_on_cancellation=return_on_cancellation,
                                behavior_on_cancellation=behavior_on_cancellation, **kwargs)

    # Check file directory
    file_dir = path.dirname
    if not file_dir.isdir and not path.isnone:
        if auto_mkdir:
            try:
                file_dir.makedir()
            except (FileNotFoundError, PermissionError) as err:
                logger.exception(err)
                messagebox.showwarning(title='Error while trying to create a directory!',
                                       message="The directory {} doesn't exist and can't be created. "
                                               "Please select another directory.".format(file_dir))
                path = _filedialog_save(return_on_cancellation=return_on_cancellation,
                                        behavior_on_cancellation=behavior_on_cancellation, **kwargs)
        else:
            messagebox.showwarning(title='Error while trying to access a directory!',
                                   message="The directory {} doesn't exist. "
                                           "Please select another directory.".format(file_dir))
            path = _filedialog_save(return_on_cancellation=return_on_cancellation,
                                    behavior_on_cancellation=behavior_on_cancellation, **kwargs)

    # Add extension
    n_path = add_file_extension(path, extension=extension, keep_existing=False, replace=replace_ext)

    # Check existing file
    if n_path.isfile:
        n_path = _handle_existing_file_conflict(n_path, overwrite=overwrite,
                                                return_on_cancellation=return_on_cancellation,
                                                behavior_on_cancellation=behavior_on_cancellation,
                                                auto_mkdir=auto_mkdir, extension=extension, backup=backup, **kwargs)
    if config_dict and n_path.isfile:  # save path in config dict
        config_dict[config_key] = n_path
    return n_path
Example #12
0
def read_data_file(path=None,
                   config_dict=None,
                   config_key=None,
                   date_columns=None,
                   sheet_name=0,
                   check_path=False,
                   ask_header=False,
                   behaviour_on_error='error',
                   open_file_kwargs=None,
                   read_kwargs=None,
                   to_datetime_kwargs=None):
    """Returns a dataframe with the content of the selected CSV or Excel file.

    :param path: file path. Must be CSV or Excel file (to be checked before with 'open_file' function).
    :param config_dict: dictionary-like object containing the configuration
    :param config_key: key of config_dict to get path (replaces path argument)
    :param date_columns: column or list of columns to format as datetime
    :param sheet_name: name or index of the Excel sheet (not used for CSV)
    :param check_path: if True, use open_file to check path
    :param ask_header: if True and key 'header' is not in read_kwargs, ask whether the file has a header or not
                       and update read_kwargs['header'] to 0 or None.
    :param behaviour_on_error: 'error' (default): raise an error, 'ignore': return None
    :param open_file_kwargs: kwargs for open_file function ONLY.
    :param read_kwargs: kwargs for read functions pd.read_excel ONLY.  # can evolve in the future
    :param to_datetime_kwargs: kwargs for pd.to_datetime function ONLY.
    :return:
    """
    path = check_path_arguments(path, config_dict, config_key)
    open_file_kwargs = open_file_kwargs or {}
    to_datetime_kwargs = to_datetime_kwargs or {}
    read_kwargs = read_kwargs or {}

    # Check if the path is correct using open_file function
    if check_path:
        open_file_kwargs[
            'path'] = path if 'path' not in open_file_kwargs else open_file_kwargs[
                'path']
        open_file_kwargs['title'] = "Open '{}' file".format(Path(path).filename) \
            if 'title' not in open_file_kwargs else open_file_kwargs['title']
        open_file_kwargs[
            'filetype'] = 'data' if 'filetype' not in open_file_kwargs else open_file_kwargs[
                'filetype']
        path = open_file(**open_file_kwargs)
    ext = path.ext

    # Ask whether the data file has an header or not.
    # If not asked (ask_header is False), header is 0 by default or can be defined in read_kwargs.
    if ask_header and 'header' not in read_kwargs:
        msg = "Does your data file '{}' has an header (column names)?".format(
            path.filename)
        res = messagebox.askyesno(title="Header", message=msg)
        read_kwargs[
            'header'] = 0 if res else None  # only level 0 can be a header

    # CSV or text file. WARN: text files must be encoded properly!
    if ext in ['.csv', '.txt']:
        df = _read_csv(path, **read_kwargs)
        logger.debug('File {} loaded in pandas dataframe.'.format(path))
        if date_columns:
            if isinstance(date_columns, str):
                date_columns = [date_columns]
            logger.debug('Converting {} columns to datetime type...'.format(
                len(date_columns)))
            for date_column in date_columns:
                df[date_column] = pd.to_datetime(df[date_column],
                                                 **to_datetime_kwargs)
            logger.debug(
                'Columns {} of file {} converted to datetime format.'.format(
                    date_columns, path))
        if config_dict:  # save path in config dict
            config_dict[config_key] = path
        return df

    # Excel file
    if ext.startswith('.xls'):
        df = _read_excel(path, sheet_name=sheet_name, **read_kwargs)
        logger.debug('File {} loaded in pandas dataframe.'.format(path))
        if config_dict:  # save path in config dict
            config_dict[config_key] = path
        return df

    # Unknown extension
    logger.error('Invalid file extension {} for file {}'.format(ext, path))
    if behaviour_on_error == 'error':
        raise ValueError(
            "No data loaded because path '{}' is invalid".format(path))
    logger.debug("None is returned.")
    return None
Example #13
0
class _Config(BaseDict):
    """Class to easily create configurations.

    >>> def_conf_d = ConfigDict({DEFAULT_SECTION: {1: 5, 2: 6, 5: 7}, 2:{1: 8, 3: 9}, 'other':{1: 10, 4: 11}})
    >>> def_conf_d.default_section

    >>> def_conf_d.section

    >>> conf_d = ConfigDict({DEFAULT_SECTION: {1: 12, 2: 13, 6: 14}, 2:{1: 15}, 'other2':{1: 16, 4: 17}})
    >>> conf_d.default_section

    >>> conf_d.section

    >>> conf_d == def_conf_d
    False
    >>> config = _Config(default_config=def_conf_d, ask_path=False)
    >>> (config.section, config.default_section)
    ('default', 'default')
    >>> config.config
    ConfigDict:
    OrderedDict([('default', SectionDict:
    OrderedDict([('1', 5), ('2', 6), ('5', 7)])), ('2', SectionDict:
    OrderedDict([('1', 8), ('3', 9)])), ('other', SectionDict:
    OrderedDict([('1', 10), ('4', 11)]))])
    >>> config.config = conf_d
    >>> (config.section, config.default_section)
    ('default', 'default')
    >>> config.config
    ConfigDict:
    OrderedDict([('default', SectionDict:
    OrderedDict([('1', 12), ('2', 13), ('6', 14)])), ('2', SectionDict:
    OrderedDict([('1', 15)])), ('other2', SectionDict:
    OrderedDict([('1', 16), ('4', 17)]))])
    >>> config[1]
    12
    >>> config['2']
    13
    >>> config[5]
    7
    >>> config.load_config(2)
    >>> config.section
    '2'
    >>> config.load_config('other')
    >>> config.section
    'other'
    >>> config.config
    ConfigDict:
    OrderedDict([('default', SectionDict:
    OrderedDict([('1', 12), ('2', 13), ('6', 14), ('5', 7)])), ('2', SectionDict:
    OrderedDict([('1', 15)])), ('other2', SectionDict:
    OrderedDict([('1', 16), ('4', 17)])), ('other', SectionDict:
    OrderedDict([('1', 10), ('4', 11)]))])
    """
    _WILDCARD = Wildcard()
    EDITABLE_ATTR = ["path", "config", "default_config", "default_section", "section"]
    EDITABLE_PRIVATE_ATTR = ["auto_load", "force_load", "conversion_dict", "auto_cast", "write_flags", "ask_path",
                             "search_in_default_config", "auto_write"]

    def __init__(self, *args, **kwargs):
        """Initialisation of a _Config instance.

        :param args:  see 'init' method
        :param kwargs: see 'init' method
        """
        super().__init__()
        self._cfg = ConfigDict()  # current configuration
        self._default_config = ConfigDict()  # default configuration
        self._temp_config = OrderedDict()  # temporary configuration
        self._path = Path()  # current configuration path
        self._default_path = Path()  # default configuration path
        self._conversion_dict = None
        self._auto_cast = None
        self._write_flags = None
        self._force_load = None
        self._load_empty = None
        self._ask_path = None
        self._search_in_default_config = None
        self._init_count = 0
        self._policies = defaultdict(bool)  # by default every modification is forbidden  # WIP
        if args or kwargs:
            self.init(*args, **kwargs)
        logger.debug("Config object created.")

    def init(self, default_config: Union[dict, ConfigDict] = None, path: Union[str, Path] = None,
             auto_load: bool = True, default_section: str = DEFAULT_SECTION, section: str = None,
             conversion_dict: dict = None, force_load: bool = False, load_empty: bool = False,
             auto_cast: bool = False, write_flags: bool = None, ask_path: bool = True,
             search_in_default_config: bool = True, merge_default_how: str = 'right', **kwargs):
        """cf. __init__

        :param path: path of the current configuration file.
        :param default_config: default configuration dictionary-like object with two levels.
        Preferred type is ConfigDict.
        :param auto_load: if True, configuration file is loaded at initialisation.
        :param default_section: string of the default section in configuration file.
        :param section: current section of current configuration
        :param conversion_dict: conversion of string values into other types.
        :param force_load: argument for load method.
        :param load_empty: if True, empty configuration can overwrite existing one
        :param auto_cast: if True, the read configuration values are automatically converted to basic Python types
        :param write_flags: if None, same as auto_cast.
                            If True, flags are added to the written configuration keys to explicit value types
        :param ask_path: if True and path is None, the configuration path is asked to the user,
        otherwise, the path remains None. Actually, it is the argument of open_file function.
        :param search_in_default_config: if True, the default configuration is automatically used when a key
        is not found in the current configuration. If a section, which exists in default config
         and doesn't in current config, is tried to being accessed, it is created in the current config " todo
         :param merge_default_how: merge method for load method.
         Default is 'outer' (both existing and new keys are kept, values are updated)
         :param kwargs: other keyword arguments (not used)
        """
        # Check arguments
        if kwargs:
            logger.warning("Keyword arguments '{}' are not valid.".format(kwargs))
        # self._check_args(path=path, default_config=default_config, auto_load=auto_load,
        #                  default_section=default_section, section=section, conversion_dict=conversion_dict,
        #                  force_load=force_load, auto_cast=auto_cast, write_flags=write_flags)

        if not isinstance(conversion_dict, dict):
            conversion_dict = {}
        # Parameters
        self._auto_cast = auto_cast
        # if write_flags is not defined, write_flags is the same as auto_cast
        self._write_flags = auto_cast if write_flags is None else write_flags
        self._force_load = force_load
        self._load_empty = load_empty
        self._ask_path = ask_path
        self._search_in_default_config = search_in_default_config  # todo
        self._conversion_dict = conversion_dict
        # Load default config
        self._default_config = ConfigDict(default_config)
        self._cfg = self.default_config.deepcopy()  # first current configuration, before load  # todo: needed?
        # Sections
        if not isinstance(default_section, str):
            default_section = DEFAULT_SECTION
        self._default_config.default_section = default_section  # default section of default config
        self._default_config.section = self._default_config.default_section if section is None else section
        self.default_section = default_section  # default section of current config
        self.section = self.default_section if section is None else section
        # Paths
        self.path = path
        self._default_path = self._path.copy() or Path(path)  # if path is None, keep the first path
        # Load configuration from file
        if auto_load:
            self.load(merge_how=merge_default_how)
        self._init_count += 1
        logger.debug("Config initialized. Number of initialization(s): {}".format(self._init_count))

    def edit(self, **kwargs):
        """Edit attributes of the configuration."""
        for attr in self.EDITABLE_ATTR:
            kwarg = kwargs.pop(attr, self._WILDCARD)
            if kwarg is not self._WILDCARD:
                setattr(self, attr, kwarg)
                logger.debug("Attribute '{}' changed to '{}'.".format(attr, kwarg))

        for p_attr in self.EDITABLE_PRIVATE_ATTR:
            kwarg = kwargs.pop(p_attr, self._WILDCARD)
            if kwarg is not self._WILDCARD:
                setattr(self, "_" + p_attr, kwarg)
                logger.debug("Private attribute '{}' changed to '{}'.".format(p_attr, kwarg))
        logger.debug("Configuration edited.")

    def __setitem__(self, key, value):
        return self._cfg.__setitem__(key, value)

    def __repr__(self):
        return "{}:\nPath: {}\nDefault section: {}\nSection: {}\n\nData: {}\n" \
            .format(self.__class__.__name__, self.path, self.default_section, self.section, self._cfg)

    def __str__(self):
        string = "# path: {}\n{}".format(self.path, self._cfg)
        return string

    # Get values
    def __getitem__(self, item):
        if item in self._temp_config:  # 1st, try to find the key in temp config
            logger.debug("temporary config used for key '{}'".format(item))
            return self._temp_config[item]
        try:
            return self._cfg[item]  # 2nd, try to find the key in current config
        except KeyError as _err_msg:
            try:  # 3rd, try to find the key in default config TODO: use search in default config
                res = self._default_config[item]
                logger.debug("Item '{}' found in default configuration instead of current configuration.".format(item))
                self._cfg[item] = res  # set item to current configuration
                return res
            except KeyError as err_msg:
                raise KeyError(err_msg)

    def get(self, item, default=None):
        try:
            return self[item]
        except KeyError:
            return default

    def setdefault(self, k, default=None):
        return self._cfg.setdefault(k, default)

    def __call__(self, *args, **kwargs):
        """Access to a value with a specific section. Pattern: CONFIG(section, key, default)

        >>> config = _Config({DEFAULT_SECTION: {1: 12, 2: 17}, 2:{1: 15}, 'other2':{1: 16, 3: 18}}, ask_path=False)
        >>> config.default_section
        'default'
        >>> config(1)  # search key 1 in default section
        12
        >>> config('other2', 1)  # search key 1 in section 'other2'
        16
        >>> config(1, section=2)  # search key 1 in section 2
        15
        >>> config(3, default=8)  # search key 3 in default section, return 8 if not found
        8
        >>> config('other2', 2, default=9)  # search key 2 in section 'other2', then default section if not found
        17

        # search key 3 in section 2, then in default section if not found; return 10 if not found

        >>> config(3, section=2, default=10)
        10

        :param args: [key] or [section, key] o [section, key, default]
        :param kwargs: {} or {'section': section} or {'default': default_value}
        :return: self.get_section(section)[key]
        default_section is used if not passed as argument
        """
        if not args or (len(args) + len(kwargs) > 3):
            raise TypeError("Excepted 1 mandatory positional argument and keyword arguments 'section' and 'default'"
                            "or 1 to 2 other positional arguments".format(kwargs.keys()))
        section = args[0] if len(args) >= 2 else kwargs.pop('section', self.default_section)
        default = args[2] if len(args) == 3 else kwargs.pop('default', self._WILDCARD)
        if kwargs:  # if other kwargs
            raise TypeError("Keyword arguments '{}' are not supported".format(kwargs.keys()))
        key = args[1] if len(args) >= 2 else args[0]
        # search in the section of the current configuration
        if key in self.get_section(section):
            return self.get_section(section)[key]
        # search in the default section of the current configuration
        elif key in self.get_section(self.default_section):
            return self.get_section(self.default_section)[key]
        # search in the section of the default configuration
        elif key in self._default_config.get_section(section) and self._search_in_default_config:
            return self._default_config.get_section(section)[key]
        # search in the default section of the default configuration
        elif key in self._default_config.get_section(self.default_section) and self._search_in_default_config:
            return self._default_config.get_section(self._default_config.default_section)[key]
        elif default is not self._WILDCARD:
            return default
        else:
            err_msg = "'{}' is not a valid key for the configuration".format(key)
            logger.error(err_msg)
            raise KeyError(err_msg)
            # return self._WILDCARD

    @property
    def temp_config(self):
        return self._temp_config

    @temp_config.setter
    def temp_config(self, other):
        self._temp_config = OrderedDict(other)
        logger.debug("Temporary configuration set to: {}".format(self._temp_config))

    # Sections
    def _check_section(self, section: Union[str, list], search_in_default_config: bool = None):
        """If the section doesn't exist and search_in_default_config,
        append the section from default config to current configuration"""
        section = None if section is None else ConfigDict.TO_KEY_FUNC(section)
        search_in_default_config = self._search_in_default_config if search_in_default_config is None \
            else search_in_default_config
        if section not in self.sections() and section is not None and search_in_default_config:
            # if the section doesn't exist, append the default configuration to the configuration
            self.add_default_config_sections(sections=section)
            # self.reload_default(write=False, how='append')  # old method
            logger.debug("Section(s) '{}' of default configuration appended to config.".format(section))
        return section

    def get_section(self, section=None, set_section=False, add_section=False, search_in_default_config=None):
        """Returns the SectionDict associated to the 'section' key

        :param section: ConfigDict key
        :param set_section: if True, set the section to current configuration
        :param add_section: if True and the section doesn't exist, create it
        :param search_in_default_config: if True and the section doesn't exist, use default config section if it exists
        :return:
        """
        section = self._check_section(section, search_in_default_config=search_in_default_config)
        return self._cfg.get_section(section=section, set_section=set_section, add_section=add_section)

    def set_section(self, section=None, add_section=True, search_in_default_config=None):
        section = self._check_section(section, search_in_default_config=search_in_default_config)
        self._cfg.set_section(section=section, add_section=add_section)

    def load_config(self, section=None):
        """alias of set_section, with search_in_default_config argument True by default."""
        self.set_section(section, search_in_default_config=True)

    def add_section(self, section, section_dict=None, auto_cast=False, exist_ok=False):
        self._cfg.add_section(section, section_dict, auto_cast=auto_cast, exist_ok=exist_ok)

    def _check_args(self, path=None, default_config=None, auto_load=True,
                    default_section=DEFAULT_SECTION, section=None, conversion_dict=None,
                    force_load=False, auto_cast=False, write_flags=None):
        # TODO
        pass

    # Updates/merges
    def merge(self, config_dict, how='outer', how_section=None, inplace=False):
        if isinstance(config_dict, _Config):
            config_dict = config_dict.config
        return self._cfg.merge(config_dict, how=how, how_section=how_section, inplace=inplace)

    def append(self, other):
        return self.merge(other, how='outer', how_section='append', inplace=True)

    def update(self, other):
        return self.merge(other, how='outer', how_section='outer', inplace=True)

    def replace(self, other):
        return self.merge(other, how='right', how_section='right', inplace=True)

    def clear(self, section=None):
        return self._cfg.clear(section=section)

    # Getters & setters of main attributes
    @property
    def init_count(self):
        return self._init_count

    @init_count.setter
    def init_count(self, _value):
        logger.error("AttributeError: 'init_count' attr can not be changed this way!")

    @property
    def default_path(self):
        return self._default_path

    @default_path.setter
    def default_path(self, _value):
        logger.error("AttributeError: 'default_path' attr can not be changed this way!")

    @property
    def path(self):
        return self._path

    @path.setter
    def path(self, path):
        if self._path.isnone:
            title = "Select a configuration file to load."
        else:
            title = "Bad configuration file set. Please select a valid configuration file."
        title += " [Cancel for default configuration]"
        self._path = open_file(path, title=title, ask_path=self._ask_path, behavior_on_cancellation='ignore')
        logger.debug("Config path set to '{}'".format(self._path))

    def get_output_path(self):
        return self.default_path if self._path.isnone else self.path

    @property
    def default_section(self):
        return self._cfg.default_section

    @default_section.setter
    def default_section(self, name):
        self._cfg.default_section = name
        # self._default_config.default_section = name

    @property
    def section(self):
        return self._cfg.section

    @section.setter
    def section(self, name):
        self._cfg.section = name

    def sections(self):  # function, not property, like Configparser.
        return [section for section in self]

    @property
    def default_config(self):  # TODO : copy ?
        return self._default_config  # .deepcopy()

    @property
    def config(self):  # TODO : copy ?
        return self._cfg

    @config.setter
    def config(self, config_dict):
        """alias to _cfg attribute

        >>> def_conf_d = ConfigDict({DEFAULT_SECTION: {1: 5, 2: 6, 5: 7}, 2:{1: 8, 3: 9}, 'other':{1: 10, 4: 11}})
        >>> conf_d = ConfigDict({DEFAULT_SECTION: {1: 12, 2: 13, 6: 14}, 2:{1: 15}, 'other2':{1: 16, 4: 17}})
        >>> config = _Config(default_config=def_conf_d)
        >>> config.config
        15

        >>> config.config = conf_d
        >>> config.config
        0

        :param config_dict:
        :return:
        """
        self._cfg.config = config_dict

    @property
    def config_dict(self):
        return self.config

    def load(self, path=None, force_load=None, auto_cast=None, load_empty=None, merge_how='right'):
        """Load configuration from file.
        If force-load, reload_default values on error.."""
        path = self._path if path is None else Path(path)
        auto_cast = self._auto_cast if auto_cast is None else auto_cast
        force_load = self._force_load if force_load is None else force_load
        load_empty = self._load_empty if load_empty is None else load_empty
        if not path.isfile:
            if force_load:
                self.reload_default()
            return None
        config_dict = self.read_config(path, auto_cast=auto_cast)
        if config_dict is None:
            if force_load:
                self.reload_default()
            return None
        elif path.isfile:  # Change path
            self._path = path
            logger.debug("Path changed to '{}' with 'load' method.".format(self.path))
        elif config_dict.isempty:
            if not load_empty:
                logger.info("Configuration loaded is empty! It won't replace the existing one.")
                logger.debug("Use 'clear' method to empty the ConfigDict")
                return None
        else:
            raise UnknownError("bad case in 'load'")
        # config_dict.section = self.section
        # config_dict.default_section = self.default_section
        # config_dict._conversion_dict = self._conversion_dict
        self.merge(config_dict, how=merge_how, inplace=True)
        logger.info("Configuration loaded!")

    def add_default_config_sections(self, sections=None, add_empty_sections=False):
        dico = self.default_config.deepcopy()
        sections = {sections} if isinstance(sections, str) else sections
        n_dico = {k: dico[k] for k in sections & dico} if sections is None else dico
        if add_empty_sections:
            for section in sections:
                self.add_section(section, exist_ok=True)
        self.merge(n_dico, how='append', inplace=True)

    def reload_default(self, write=True, backup=True, how='right', how_section=None, sections=None):
        """Set path and config to default values.
        If write is True, overwrite default file with default configuration."""
        # self._cfg = self.default_config.deepcopy()
        self._path = self._default_path.copy()
        dico = self.default_config.deepcopy()
        sections = {sections} if isinstance(sections, str) else sections
        n_dico = dico if sections is None else {k: dico[k] for k in sections & dico.keys()}
        self.merge(n_dico, how=how, how_section=how_section, inplace=True)
        if write:
            self.save_config(overwrite=True, backup=backup)
            logger.info("Configuration reloaded and saved to '{}'.".format(self._path))
        else:
            logger.info("Configuration reloaded.")

    # Read methods

    # def read(self, *args, **kwargs):  # deprecated
    #     if args and isinstance(args[0], io.TextIOBase):  # Support of Configparser behavior
    #         logger.warning("Bad practice: do not pass a file through 'write' method, prefer a path!")
    #         args[0].close()
    #         args = (args[0].name, *args[1:])
    #     return self.read_config(*args, **kwargs)

    @classmethod
    def read_config(cls, path, auto_cast=True, anomaly_flag='warning'):
        """Returns a config_dict read from a INI file."""
        # WARNING: No check of input arguments !
        # Read the configuration file with configparser
        config_parser = configparser.ConfigParser()
        try:
            config_parser.read(path, encoding=ENCODING)
        except (configparser.Error, ValueError, KeyError, TypeError) as err:
            logger.exception(err)
            msg = "The configuration could not be loaded!\n" \
                  "Please check that the configuration file is correct.\n" \
                  "Original error: {}".format(err)
            raise_anomaly(flag=anomaly_flag, error=err.__class__,
                          title="Configuration loading failed!", message=msg)
            return None
        return ConfigDict(config_parser, auto_cast=auto_cast)

    # Write methods
    def save_config(self, path=None, overwrite=True, backup=False, auto_mkdir=True,
                    write_flags=None, anomaly_flag='warning', ask_path=False, default_config=False):
        write_flags = self._write_flags if write_flags is None else write_flags
        path = self.get_output_path() if path is None else path
        path = save_file(path, ask_path=ask_path, overwrite=overwrite,
                         backup=backup, auto_mkdir=auto_mkdir)
        if path.isnone:
            logger.error("No valid path set. Configuration has not been saved.")
            return
        config_dict = self.default_config if default_config else self.config
        path = self.write_config(path, config_dict, write_flags=write_flags, anomaly_flag=anomaly_flag)
        if path is not None:
            self._path = path

    # def write_config(cls, *args, **kwargs):  # NXVER
    #     return ConfigDict.write_config(*args, **kwargs)

    # def write(self, *args, **kwargs):  # deprecated
    #     if args and isinstance(args[0], io.TextIOBase):  # Support of Configparser behavior
    #         logger.warning("Bad practice: do not pass a file through 'write' method, prefer a path!")
    #         args[0].close()
    #         args = (args[0].name, *args[1:])
    #     return self.write_config(*args, **kwargs)

    @staticmethod
    def _write_configparser(config_dict, write_flags=False):
        config_parser = configparser.ConfigParser()
        for section in config_dict:
            config_parser.add_section(section)
            section_dict = convert_dict_to_str(config_dict.get(section)) \
                if write_flags else config_dict.get(section)
            for key, value in section_dict.items():
                config_parser.set(section, str(key), str(value))
        return config_parser

    @classmethod
    def write_config_bug(cls, path, config_dict, backup=True, overwrite=True,
                         auto_mkdir=True, write_flags=False, anomaly_flag='warning'):  # todo buggy method
        # WARNING: No check of input arguments !
        # Get path
        path = save_file(path, overwrite=overwrite, backup=backup, auto_mkdir=auto_mkdir)
        # Load Configparser object
        config_parser = cls._write_configparser(config_dict, write_flags=write_flags)
        # Write Configparser object to disk
        try:
            with open(path, mode='w', encoding=ENCODING, newline=None) as file:
                config_parser.write(file)
        except (configparser.Error, PermissionError, FileNotFoundError) as err:
            logger.exception(err)
            msg = "Configuration could not be written to disk!\n" \
                  "Original error: {}".format(err)
            raise_anomaly(flag=anomaly_flag, error=err.__class__,
                          title="Configuration writing failed!", message=msg)
            return None
        logger.info("Configuration successfully written to disk: {}".format(path))
        # logger.debug("Configuration written: {}".format(config_dict))
        return path

    @classmethod
    def write_config(cls, path, config_dict, write_flags=False, anomaly_flag='warning'):
        # WARNING: No check of input arguments !
        # config_dict = convert_dict_to_str(config_dict) if write_flags else config_dict  # WRONG: ConfigDict not convertible!
        # Write Configparser object to disk
        try:
            with open(path, mode='w', encoding=ENCODING, newline=None) as file:
                file.write(config_dict.to_str(write_flags=write_flags))
        except (configparser.Error, PermissionError, FileNotFoundError) as err:
            logger.exception(err)
            msg = "Configuration could not be written to disk!\n" \
                  "Original error: {}".format(err)
            raise_anomaly(flag=anomaly_flag, error=err.__class__,
                          title="Configuration writing failed!", message=msg)
            return None
        logger.info("Configuration successfully written to disk: {}".format(path))
        # logger.debug("Configuration written: {}".format(config_dict))
        return path
Example #14
0
    def init(self, default_config: Union[dict, ConfigDict] = None, path: Union[str, Path] = None,
             auto_load: bool = True, default_section: str = DEFAULT_SECTION, section: str = None,
             conversion_dict: dict = None, force_load: bool = False, load_empty: bool = False,
             auto_cast: bool = False, write_flags: bool = None, ask_path: bool = True,
             search_in_default_config: bool = True, merge_default_how: str = 'right', **kwargs):
        """cf. __init__

        :param path: path of the current configuration file.
        :param default_config: default configuration dictionary-like object with two levels.
        Preferred type is ConfigDict.
        :param auto_load: if True, configuration file is loaded at initialisation.
        :param default_section: string of the default section in configuration file.
        :param section: current section of current configuration
        :param conversion_dict: conversion of string values into other types.
        :param force_load: argument for load method.
        :param load_empty: if True, empty configuration can overwrite existing one
        :param auto_cast: if True, the read configuration values are automatically converted to basic Python types
        :param write_flags: if None, same as auto_cast.
                            If True, flags are added to the written configuration keys to explicit value types
        :param ask_path: if True and path is None, the configuration path is asked to the user,
        otherwise, the path remains None. Actually, it is the argument of open_file function.
        :param search_in_default_config: if True, the default configuration is automatically used when a key
        is not found in the current configuration. If a section, which exists in default config
         and doesn't in current config, is tried to being accessed, it is created in the current config " todo
         :param merge_default_how: merge method for load method.
         Default is 'outer' (both existing and new keys are kept, values are updated)
         :param kwargs: other keyword arguments (not used)
        """
        # Check arguments
        if kwargs:
            logger.warning("Keyword arguments '{}' are not valid.".format(kwargs))
        # self._check_args(path=path, default_config=default_config, auto_load=auto_load,
        #                  default_section=default_section, section=section, conversion_dict=conversion_dict,
        #                  force_load=force_load, auto_cast=auto_cast, write_flags=write_flags)

        if not isinstance(conversion_dict, dict):
            conversion_dict = {}
        # Parameters
        self._auto_cast = auto_cast
        # if write_flags is not defined, write_flags is the same as auto_cast
        self._write_flags = auto_cast if write_flags is None else write_flags
        self._force_load = force_load
        self._load_empty = load_empty
        self._ask_path = ask_path
        self._search_in_default_config = search_in_default_config  # todo
        self._conversion_dict = conversion_dict
        # Load default config
        self._default_config = ConfigDict(default_config)
        self._cfg = self.default_config.deepcopy()  # first current configuration, before load  # todo: needed?
        # Sections
        if not isinstance(default_section, str):
            default_section = DEFAULT_SECTION
        self._default_config.default_section = default_section  # default section of default config
        self._default_config.section = self._default_config.default_section if section is None else section
        self.default_section = default_section  # default section of current config
        self.section = self.default_section if section is None else section
        # Paths
        self.path = path
        self._default_path = self._path.copy() or Path(path)  # if path is None, keep the first path
        # Load configuration from file
        if auto_load:
            self.load(merge_how=merge_default_how)
        self._init_count += 1
        logger.debug("Config initialized. Number of initialization(s): {}".format(self._init_count))