blob: 0376fce634404518194a42d33f2cc342a13d42c1 [file] [log] [blame]
# 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()