AutoArchive._application.archiving.archive_spec.archive_spec — AutoArchive Developer Manual (ver. 2.1.0.dev20260309)

# Source code for AutoArchive._application.archiving.archive_spec.archive_spec

    # archive_spec.py
    #
    # Project: AutoArchive
    # License: GNU GPLv3
    #
    # Copyright (C) 2003 - 2025 Róbert Čerňanský

    __all__ = ["ArchiveSpec"]

    import configparser
    import itertools
    import os
    import re
    from typing import Any

    from AutoArchive._infrastructure.configuration import ConfigurationBase, Options, OptionsUtils, Option
    from . import ArchiveSpecOptions
    from ._archive_spec_config_parser import _ArchiveSpecConfigParser
    from ._sections import _Sections

    class ArchiveSpec(ConfigurationBase):
        """The :term:`archive` specification.

        Contains all information needed to create the :term:`backup` such as the name, list of files which shall be
        included into the backup, list of files to exclude, etc.  These values can be configured in the
        :term:`archive specification file` (``specFile``) or in the general configuration such as command line or
        configuration files.  Options that can be read from this class are defined as static attributes of
        :class:`.ArchiveSpecOptions` and :class:`.Options`.  If an option is not defined in the
        archive specification file it is read from ``configuration``.

        The instance is fully populated during construction.

        :param specFile: :term:`Archive specification file` name (the “.aa file”).
        :type specFile: ``str``
        :param configuration: The application's configuration.
        :type configuration: :class:`.ConfigurationBase`
        :param componentUi: The application's :term:`UI` interface.  If ``None`` then messages about non-accessible
           files or other errors during processing of **included** and **excluded** file lists will not be shown.
        :type componentUi: :class:`.CmdlineUi`

        :raise OSError: If ``specFile`` can not be opened.
        :raise LookupError: If a *section* or an *option* is missing in ``specFile``.
        :raise SyntaxError: If ``specFile`` can not be parsed.
        :raise KeyError: If an invalid (unsupported) *section* or *option* is found in the :term:`archive specification
           file`.
        :raise ValueError: If option's *value* is not correct."""

        # configuration options that are also supported in the arch. spec. file
        __CONFIG_OPTIONS = frozenset({Options.ARCHIVER,
                                      Options.COMPRESSION_LEVEL,
                                      Options.DEST_DIR,
                                      Options.OVERWRITE_AT_START,
                                      Options.INCREMENTAL,
                                      Options.RESTARTING,
                                      Options.RESTART_AFTER_LEVEL,
                                      Options.RESTART_AFTER_AGE,
                                      Options.FULL_RESTART_AFTER_COUNT,
                                      Options.FULL_RESTART_AFTER_AGE,
                                      Options.MAX_RESTART_LEVEL_SIZE,
                                      Options.REMOVE_OBSOLETE_BACKUPS,
                                      Options.KEEP_OLD_BACKUPS,
                                      Options.NUMBER_OF_OLD_BACKUPS,
                                      Options.COMMAND_BEFORE_BACKUP,
                                      Options.COMMAND_AFTER_BACKUP})

        def __init__(self, specFile, configuration, componentUi = None):
            super().__init__()

            self.__configuration = configuration
            self.__componentUi = componentUi

            self.__spec = _ArchiveSpecConfigParser(
                forbiddenOptions = {str(opt) for opt in set(OptionsUtils.getAllOptions()) - self.__CONFIG_OPTIONS},
                archiveSpecsDir = configuration[Options.ARCHIVE_SPECS_DIR])

            # read the archive specification file (the .aa file)
            self.__spec.read_file(open(specFile))

            # populate the instance
            self.__addRequiredSpecFileOptions(specFile)
            self.__addOptionalSpecFileOptions(specFile)
            self.__addOptionalConfigurationOptions()

        # {{{ ConfigurationBase overrides

        def getRawValue(self, option: Option) -> Any | None:
            "See: :meth:`.ConfigurationBase.getRawValue()`."

            if option in self.options_:
                return super().getRawValue(option)
            else:
                return self.__configuration.getRawValue(option)

        def isOptionPresent(self, option: Option) -> bool:
            if option in self.options_:
                return self.options_[option].value is not None
            else:
                return self.__configuration.isOptionPresent(option)

        # }}} ConfigurationBase overrides

        # {{{ helpers

        # {{{ options adding

        def __addRequiredSpecFileOptions(self, specFile):
            "Add non-optional options (specific to archive spec. file)."

            try:
                self.__addOptionFromSpec(_Sections.CONTENT, ArchiveSpecOptions.PATH)

                self.__setitem__(
                    ArchiveSpecOptions.INCLUDE_FILES, self.__readFilesLists(str(ArchiveSpecOptions.INCLUDE_FILES)))
                self.__setitem__(
                    ArchiveSpecOptions.EXCLUDE_FILES, self.__readFilesLists(str(ArchiveSpecOptions.EXCLUDE_FILES)))

            except configparser.NoSectionError as ex:
                raise LookupError(str.format(
                    "Missing section \"{}\" in specification file \"{}\".", ex.section, specFile))
            except configparser.NoOptionError as ex:
                raise LookupError(str.format(
                    "Missing option \"{}\" in section \"{}\" of specification file \"{}\".",
                    ex.option, ex.section, specFile))

        def __addOptionalSpecFileOptions(self, specFile):
            "Add optional options specific to archive spec. file."

            self.__addOptionFromSpec(
                _Sections.CONTENT, ArchiveSpecOptions.NAME, os.path.splitext(os.path.basename(specFile))[0])

        def __addOptionalConfigurationOptions(self):
            "Add optional options that can be present also in configuration."

            if self.__spec.has_section(_Sections.ARCHIVE):
                for option in self.__CONFIG_OPTIONS:
                    self.__tryAddOptionFromSpec(_Sections.ARCHIVE, option)

        # {{{ options adding sub-helpers

        def __addOrReplaceOption(self, option: Option, value: str):
            try:
                self.__setitem__(option, OptionsUtils.strToOptionType(option, value))
            except ValueError:
                raise ValueError(str.format(
                    "Wrong value \"{}\" of the option \"{}\" in the archive specification file.", value, option))

        def __addOptionFromSpec(self, section, option, default = None):
            self.__addOrReplaceOption(option, self.__spec.get(section, str(option), fallback = default))

        def __tryAddOptionFromSpec(self, section, option):
            if self.__spec.has_option(section, str(option)):
                self.__addOrReplaceOption(option, self.__spec.get(section, str(option)))

        # {{{ file lists processing

        def __readFilesLists(self, listKind):
            "Read configured include or exclude files or directories."

            # extract filenames from specFile and store them into list
            files = re.findall(r'(?:".+?")|(?:\S+)', self.__spec.get(_Sections.CONTENT, listKind))

            # previous regexp leaves quotes in filenames so they must be removed
            files = self.__clearQuotes(files)

            # remove parent directory path elements and absolute path token ("/"); for paths like "../../foo/bar" it
            # returns "foo/bar" and for absolute paths like "/bar/baz" it returns "bar/baz";
            # for each path in files it splits it by "/" and uses dropwhile() function to filter-out
            # first "/" (empty pathElement) and ".." (pathElement == os.pardir); then joins the result back to the path
            # representation
            return frozenset((os.path.join(*(itertools.dropwhile(
                lambda pathElement: not pathElement or pathElement == os.pardir, os.path.normpath(path).split(os.sep))))
                              for path in files))

        @staticmethod
        def __clearQuotes(files):
            "Remove quotes from file names."

            for fileName in files:
                match = re.search('(?<=").+(?=")', fileName)
                if match is not None:
                    yield match.group(0)
                else:
                    yield fileName

        # }}} file lists processing

        # }}} options adding sub-helpers

        # }}} options adding

        # }}} helpers