# This source file is part of the Swift.org open source project # # Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # # See https://swift.org/LICENSE.txt for license information # See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors """ Swift preset parsing and handling functionality. """ from __future__ import absolute_import, unicode_literals from collections import namedtuple from contextlib import contextmanager try: # Python 2 import ConfigParser as configparser from StringIO import StringIO except ImportError: import configparser from io import StringIO __all__ = [ 'Error', 'DuplicatePresetError', 'DuplicateOptionError', 'InterpolationError', 'PresetNotFoundError', 'UnparsedFilesError', 'Preset', 'PresetParser', ] # ----------------------------------------------------------------------------- _PRESET_PREFIX = 'preset: ' _Mixin = namedtuple('_Mixin', ['name']) _Argument = namedtuple('_Argument', ['name', 'value']) _RawPreset = namedtuple('_RawPreset', ['name', 'options']) def _interpolate_string(string, values): if string is None: return string return string % values def _remove_prefix(string, prefix): if string.startswith(prefix): return string[len(prefix):] return string @contextmanager def _catch_duplicate_option_error(): """Shim context object used for catching and rethrowing configparser's DuplicateOptionError, which was added in the Python 3 refactor. """ if hasattr(configparser, 'DuplicateOptionError'): try: yield except configparser.DuplicateOptionError as e: preset_name = _remove_prefix(e.section, _PRESET_PREFIX) raise DuplicateOptionError(preset_name, e.option) else: yield @contextmanager def _catch_duplicate_section_error(): """Shim context object used for catching and rethrowing configparser's DuplicateSectionError. """ try: yield except configparser.DuplicateSectionError as e: preset_name = _remove_prefix(e.section, _PRESET_PREFIX) raise DuplicatePresetError(preset_name) @contextmanager def _convert_configparser_errors(): with _catch_duplicate_option_error(), _catch_duplicate_section_error(): yield # ----------------------------------------------------------------------------- # Error classes class Error(Exception): """Base class for preset errors. """ def __init__(self, message=''): super(Error, self).__init__(self, message) self.message = message def __str__(self): return self.message __repr__ = __str__ class DuplicatePresetError(Error): """Raised when an existing preset would be overriden. """ def __init__(self, preset_name): Error.__init__(self, '{} already exists'.format(preset_name)) self.preset_name = preset_name class DuplicateOptionError(Error): """Raised when an option is repeated in a single preset. """ def __init__(self, preset_name, option): Error.__init__(self, '{} already exists in preset {}'.format( option, preset_name)) self.preset_name = preset_name self.option = option class InterpolationError(Error): """Raised when an error is encountered while interpolating use-provided values in preset arguments. """ def __init__(self, preset_name, option, rawval, reference): Error.__init__(self, 'no value found for {} in "{}"'.format( reference, rawval)) self.preset_name = preset_name self.option = option self.rawval = rawval self.reference = reference class PresetNotFoundError(Error): """Raised when a requested preset cannot be found. """ def __init__(self, preset_name): Error.__init__(self, '{} not found'.format(preset_name)) self.preset_name = preset_name class UnparsedFilesError(Error): """Raised when an error was encountered parsing one or more preset files. """ def __init__(self, filenames): Error.__init__(self, 'unable to parse files: {}'.format(filenames)) self.filenames = filenames # ----------------------------------------------------------------------------- class Preset(namedtuple('Preset', ['name', 'args'])): """Container class used to wrap preset names and expanded argument lists. """ # Keeps memory costs low according to the docs __slots__ = () def format_args(self): """Format argument pairs for use in the command line. """ args = [] for (name, value) in self.args: if value is None: args.append(name) else: args.append('{}={}'.format(name, value)) return args class PresetParser(object): """Parser class used to read and manipulate Swift preset files. """ def __init__(self): self._parser = configparser.RawConfigParser(allow_no_value=True) self._presets = {} def _parse_raw_preset(self, section): preset_name = _remove_prefix(section, _PRESET_PREFIX) try: section_items = self._parser.items(section) except configparser.InterpolationMissingOptionError as e: raise InterpolationError(preset_name, e.option, e.rawval, e.reference) args = [] for (option, value) in section_items: # Ignore the '--' separator, it's no longer necessary if option == 'dash-dash': continue # Parse out mixin options if option == 'mixin-preset': lines = value.strip().splitlines() args += [_Mixin(option.strip()) for option in lines] continue option = '--' + option # Format as a command-line option args.append(_Argument(option, value)) return _RawPreset(preset_name, args) def _parse_raw_presets(self): for section in self._parser.sections(): # Skip all non-preset sections if not section.startswith(_PRESET_PREFIX): continue raw_preset = self._parse_raw_preset(section) self._presets[raw_preset.name] = raw_preset def read(self, filenames): """Reads and parses preset files. Throws an UnparsedFilesError if any of the files couldn't be read. """ with _convert_configparser_errors(): parsed_files = self._parser.read(filenames) unparsed_files = set(filenames) - set(parsed_files) if len(unparsed_files) > 0: raise UnparsedFilesError(list(unparsed_files)) self._parse_raw_presets() def read_file(self, file): """Reads and parses a single file. """ self.read([file]) def read_string(self, string): """Reads and parses a string containing preset definintions. """ fp = StringIO(string) with _convert_configparser_errors(): # ConfigParser changes drastically from Python 2 to 3 if hasattr(self._parser, 'read_file'): self._parser.read_file(fp) else: self._parser.readfp(fp) self._parse_raw_presets() def _get_preset(self, name): preset = self._presets.get(name) if preset is None: raise PresetNotFoundError(name) if isinstance(preset, _RawPreset): preset = self._resolve_preset_mixins(preset) # Cache resolved preset self._presets[name] = preset return preset def _resolve_preset_mixins(self, raw_preset): """Resolve all mixins in a preset, fully expanding the arguments list. """ assert isinstance(raw_preset, _RawPreset) # Expand mixin arguments args = [] for option in raw_preset.options: if isinstance(option, _Mixin): args += self._get_preset(option.name).args elif isinstance(option, _Argument): args.append((option.name, option.value)) else: # Should be unreachable raise ValueError('invalid argument type: {}', option.__class__) return Preset(raw_preset.name, args) def _interpolate_preset_vars(self, preset, vars): interpolated_args = [] for (name, value) in preset.args: try: value = _interpolate_string(value, vars) except KeyError as e: raise InterpolationError(preset.name, name, value, e.args[0]) interpolated_args.append((name, value)) return Preset(preset.name, interpolated_args) def get_preset(self, name, raw=False, vars=None): """Returns the preset with the requested name or throws a PresetNotFoundError. If raw is False vars will be interpolated into the preset arguments. Otherwise presets will be returned without interpolation. Presets are retrieved using a dynamic caching algorithm that expands only the requested preset and it's mixins recursively. Every expanded preset is then cached. All subsequent expansions or calls to `get_preset` for any pre-expanded presets will use the cached results. """ vars = vars or {} preset = self._get_preset(name) if not raw: preset = self._interpolate_preset_vars(preset, vars) return preset def preset_names(self): """Returns a list of all parsed preset names. """ return self._presets.keys()