Source code for nunavut.lang._config

#
# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
# Copyright (C) 2018-2020  OpenCyphal Development Team  <opencyphal.org>
# This software is distributed under the terms of the MIT License.
#
"""Logic for parsing language configuration.

"""
import re
import types
import typing

from enum import auto, Enum

from yaml import Loader as YamlLoader
from yaml import load as yaml_loader
from nunavut._utilities import deep_update

NUNAVUT_LANG_CPP = "nunavut.lang.cpp"


class ConstructorConvention(Enum):
    Default = "default"
    UsesLeadingAllocator = "uses-leading-allocator"
    UsesTrailingAllocator = "uses-trailing-allocator"

    @staticmethod
    def parse_string(s: str) -> typing.Optional[typing.Any]:  # annoying mypy cheat due to returning type being defined
        for e in ConstructorConvention:
            if s == e.value:
                return e
        return None


class SpecialMethod(Enum):
    DefaultConstructorWithOptionalAllocator = auto()
    CopyConstructorWithAllocator = auto()
    MoveConstructorWithAllocator = auto()


[docs]class LanguageConfig: """ Configuration storage encapsulating parsers and other configuration format details. For any configuration type used the concept of "sections" must be maintained which requires that the top-level configuration be structured as key/value pairs with the keys using the form "nunavut.lang.[language name]". For example, yaml configuration must have a top-level structure like this: .. code-block:: python example_yaml = ''' nunavut.lang.a: key_one: value_one key_two: value_two nunavut.lang.b: key_one: value_one key_two: value_two nunavut.lang.c: key_one: value_one key_two: value_two ''' .. invisible-code-block: python from nunavut.lang import LanguageConfig config = LanguageConfig() config.update_from_string(example_yaml) data = config.sections() assert len(data) == 3 assert data['nunavut.lang.b']['key_two'] == 'value_two' .. note:: The "language name" part of the section identifier must not start with a number and can contain only alphanumeric characters. That is, the section identifier must match this pattern: nunavut\\.lang\\.[a-zA-Z]{1}\\w* The values of the section data can be anything: .. code-block:: python example_yaml = ''' nunavut.lang.d: key_one: - is - a - list: where: index2 is: a_dictionary ''' .. invisible-code-block: python config.update_from_string(example_yaml) assert 'a_dictionary' == config.sections()['nunavut.lang.d']['key_one'][2]['list']['is'] """ SECTION_NAME_PATTERN = re.compile( r"^nunavut\.lang\.([a-zA-Z]{1}\w*)$" ) #: Required pattern for section name identifers. def __init__(self): # type: ignore self._sections = dict() # type: typing.Dict[str, typing.Dict[str, typing.Any]]
[docs] def update(self, configuration: typing.Any) -> None: """ Add configuration data to this configuration from a string. Unlike add_section, this method will update section data with existing keys. For example, the first update is to an empty configuration so it will act as a simple insert operation: .. invisible-code-block: python from nunavut.lang import LanguageConfig .. code-block: python initial_data = { 'nunavut.lang.a': { 'key_one': 'value_one', 'key_two': 'value_two' }, 'nunavut.lang.b': { 'key_one': [ 'item_0', { 'item_1_value_0': 0, 'item_1_value_1': 1 }, 'item_2' ], 'key_two': 'value_two' } } config = LanguageConfig() config.update(initial_data) assert config.sections()['nunavut.lang.a']['key_one'] == 'value_one' assert config.sections()['nunavut.lang.b']['key_one'][1]['item_1_value_1'] == 1 assert config.sections()['nunavut.lang.b']['key_two'] == 'value_two' ...but updating this data is now possible where sections can be added and updated: .. code-block: python updated_data = ''' nunavut.lang.b: key_one: simple key_three: value_three nunavut.lang.c: key_one: new_language_key ''' updated_data = { 'nunavut.lang.b': { 'key_one': 'simple', 'key_three': 'value_three' }, 'nunavut.lang.c': { 'key_one': 'new_language_key' }, } config.update(updated_data) assert config.sections()['nunavut.lang.a']['key_one'] == 'value_one' assert config.sections()['nunavut.lang.b']['key_one'] == 'simple' assert config.sections()['nunavut.lang.b']['key_two'] == 'value_two' assert config.sections()['nunavut.lang.b']['key_three'] == 'value_three' assert config.sections()['nunavut.lang.c']['key_one'] == 'new_language_key' .. invisible-code-block: python tests = [ ( { 'did_not_start.with.nunavut': { 'key_one': 'value_one' } }, ValueError() ), ( { 'nunavut.lang.0c': { 'key_one': 'value_one' } }, ValueError() ), ( { 1: { 'key_one': 'value_one' } }, TypeError() ), ( { 'nunavut.lang.c.iso': { 'key_one': 'value_one' } }, ValueError() ) ] for test_tuple in tests: try: config.update(test_tuple[0]) assert False except BaseException as e: assert isinstance(e, type(test_tuple[1])) pass """ # validate the (very loose) configuration schema. for section_name, section_data in configuration.items(): if not isinstance(section_name, str): raise TypeError("section names must be strings") if not self.SECTION_NAME_PATTERN.match(section_name): raise ValueError( 'Section name "{}" is invalid. See LanguageConfig documentation for rules.'.format(section_name) ) self.update_section(section_name, section_data)
def update_section(self, section_name: str, configuration: typing.Any) -> None: self._sections[section_name] = deep_update(self._sections.get(section_name, {}), configuration) def sections(self) -> typing.Dict[str, typing.Dict[str, typing.Any]]: return self._sections def update_from_string(self, string: str, context: typing.Optional[str] = None) -> None: configuration = yaml_loader(string, Loader=YamlLoader) self.update(configuration) def update_from_file(self, f: typing.TextIO, context: typing.Optional[str] = None) -> None: configuration = yaml_loader(f, Loader=YamlLoader) self.update(configuration) def set(self, section: str, option: str, value: typing.Any) -> None: self._sections[section][option] = value
[docs] def add_section(self, section_name: str) -> None: """Add a section to the configuration. Sections are top-level containers that contain key/value pairs of configuration of a single language type. :param section_name: The name of the language section. This must adhere to the :data:`SECTION_NAME_PATTERN` pattern. .. invisible-code-block: python from nunavut.lang import LanguageConfig config = LanguageConfig() try: config.add_section(53) assert "add_section must throw TypeError if given non-string key." except TypeError: pass try: config.add_section("foo") assert "add_section must throw ValueError if given an invalid string name." except ValueError: pass config.add_section("nunavut.lang.c") try: config.add_section("nunavut.lang.c") assert "add_section must throw ValueError if section redefinition is attempted." except ValueError: pass """ if not isinstance(section_name, str): raise TypeError("section names must be strings") if not self.SECTION_NAME_PATTERN.match(section_name): raise ValueError( 'Section name "{}" is invalid. See LanguageConfig documentation for rules.'.format(section_name) ) if section_name in self._sections: raise ValueError("Section {} is already defined.".format(section_name)) self._sections[section_name] = dict()
_UNSET = object() # Used internally to allow "None" as a default value. def _get_config_value_raw(self, section_name: str, key: str, default_value: typing.Any) -> typing.Any: """ .. invisible-code-block: python from nunavut.lang import LanguageConfig .. code-block: python test_data = { 'nunavut.lang.a': { 'key_one': 'value_one' }, 'nunavut.lang.b': { 'key_one': 'value_one' } } config = LanguageConfig() config.update(initial_data) try: config.get_config_value('nunavut.lang.c', 'foo') assert False except KeyError: pass assert 'bar' == config.get_config_value('nunavut.lang.c', 'foo', 'bar') try: config.get_config_value('nunavut.lang.a', 'foo') assert False except KeyError: pass assert 'bar' == config.get_config_value('nunavut.lang.a', 'foo', 'bar') assert 'value_one' == config.get_config_value('nunavut.lang.a', 'key_one') assert 'value_one' == config.get_config_value('nunavut.lang.a', 'key_one', 'bar') """ try: section_data = self._sections[section_name] except KeyError: if default_value is not self._UNSET: return default_value else: raise try: return section_data[key] except KeyError: if default_value is not self._UNSET: return default_value else: raise
[docs] def get_config_value(self, section_name: str, key: str, default_value: typing.Optional[str] = None) -> str: """ Get an optional language property from the language configuration. :param section_name : The name of the section to get the value from. :param str key : The config value to retrieve. :param default_value: The value to return if the key was not in the configuration. If provided this method will\ not raise. :type default_value : typing.Optional[str] :return: Either the value from the config or the default_value if provided. :rtype: str :raises: KeyError if the section or the key in the section does not exist and a default_value was not provided. .. invisible-code-block: python from nunavut.lang import LanguageConfig .. code-block: python test_data = { 'nunavut.lang.a': { 'key_one': [ 1, 2 ] }, 'nunavut.lang.b': { 'key_one': 'value_one' } } config = LanguageConfig() config.update(test_data) assert 'value_one' == config.get_config_value('nunavut.lang.b', 'key_one') assert 'value_one' == config.get_config_value('nunavut.lang.b', 'key_one', 'bar') assert 'bar' == config.get_config_value('nunavut.lang.b', 'key_two', 'bar') try: config.get_config_value('nunavut.lang.b', 'key_two') assert False # supposed to throw without a default value. except KeyError: pass """ optional_result = self._get_config_value_raw( section_name, key, (self._UNSET if default_value is None else default_value) ) # when we've retrieved a None result the str() cast will return "None" which isn't our intent. # Instead, if we get None and the _get_config_value_raw didn't throw a KeyError then what we really # meant is that we wanted an empty string if the value existed but was None. return str(optional_result) if optional_result is not None else ""
[docs] def get_config_value_as_bool(self, section_name: str, key: str, default_value: bool = False) -> bool: """ Get an optional language property from the language configuration returning a boolean. The rules for boolean conversion are as follows: .. invisible-code-block: python from nunavut.lang import LanguageConfig config = LanguageConfig() config.add_section('nunavut.lang.cpp') .. code-block:: python # "Any string" = True config.set('nunavut.lang.cpp', 'v', 'Any string') assert config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "true" = True config.set('nunavut.lang.cpp', 'v', 'true') assert config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "TrUe" = True config.set('nunavut.lang.cpp', 'v', 'TrUe') assert config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "1" = True config.set('nunavut.lang.cpp', 'v', '1') assert config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "false" = False config.set('nunavut.lang.cpp', 'v', 'false') assert not config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "FaLse" = False config.set('nunavut.lang.cpp', 'v', 'FaLse') assert not config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "0" = False config.set('nunavut.lang.cpp', 'v', '0') assert not config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # "" = False config.set('nunavut.lang.cpp', 'v', '') assert not config.get_config_value_as_bool('nunavut.lang.cpp', 'v') # False if not defined assert not config.get_config_value_as_bool('nunavut.lang.cpp', 'not_a_key') # True if not defined but default_value is True assert not config.get_config_value_as_bool('nunavut.lang.cpp', 'not_a_key') :param section_name : The name of the section to get the value from. :param str key : The config value to retrieve. :param bool default_value : The value to use if no value existed. :return : The config value as either True or False. :rtype : bool """ result = self.get_config_value(section_name, key, default_value="false" if not default_value else "true") if result.lower() == "false" or result == "0": return False else: return bool(result)
[docs] def get_config_value_as_dict( self, section_name: str, key: str, default_value: typing.Optional[typing.Dict] = None ) -> typing.Dict[str, typing.Any]: """ Get a language property parsing it as a map with string keys. Example: .. invisible-code-block: python from nunavut.lang import LanguageConfig config = LanguageConfig() config.add_section('nunavut.lang.a') .. code-block: python config.set('nunavut.lang.a', 'foo', {'one': 1}) assert config.get_config_value_as_dict('nunavut.lang.a', 'foo')['one'] == 1 .. invisible-code-block: python try: config.get_config_value_as_dict('nunavut.lang.b', 'foo') assert False except KeyError: pass assert config.get_config_value_as_dict('nunavut.lang.b', 'foo', {'one': 2})['one'] == 2 try: config.get_config_value_as_dict('nunavut.lang.a', 'bar') assert False except KeyError: pass assert config.get_config_value_as_dict('nunavut.lang.a', 'bar', {'one': 2})['one'] == 2 config.set('nunavut.lang.a', 'bar', 1) try: config.get_config_value_as_dict('nunavut.lang.a', 'bar') assert False except TypeError: pass assert config.get_config_value_as_dict('nunavut.lang.a', 'bar', {'one': 2})['one'] == 2 :param str section_name : The name of the section to get the key from. :param str key : The config value to retrieve. :param default_value : The value to return if the key was not in the configuration. If provided this method\ will not raise a KeyError nor a TypeError. :type default_value : typing.Optional[typing.Mapping[str, typing.Any]] :return : Either the value from the config or the default_value if provided. :rtype : typing.Mapping[str, typing.Any] :raises : KeyError if the key does not exist and a default_value was not provided. :raises : TypeError if the value exists but is not a dict and a default_value was not provided. """ raw_value = self._get_config_value_raw( section_name, key, default_value=(self._UNSET if default_value is None else default_value) ) if isinstance(raw_value, dict): return raw_value if default_value is None: raise TypeError("{}.{} exists but is not a dict. (is type {})".format(section_name, key, type(raw_value))) return default_value
[docs] def get_config_value_as_list( self, section_name: str, key: str, default_value: typing.Optional[typing.List] = None ) -> typing.List[typing.Any]: """Get a language property parsing it as a map with string keys. Example: .. invisible-code-block: python from nunavut.lang import LanguageConfig config = LanguageConfig() config.add_section('nunavut.lang.a') .. code-block: python config.set('nunavut.lang.a', 'foo', [1, 2, 3]) assert config.get_config_value_as_list('nunavut.lang.a', 'foo')[1] == 2 .. invisible-code-block: python try: config.get_config_value_as_list('nunavut.lang.b', 'foo') assert False except KeyError: pass assert config.get_config_value_as_list('nunavut.lang.b', 'foo', [2, 3, 4])[1] == 3 try: config.get_config_value_as_list('nunavut.lang.a', 'bar') assert False except KeyError: pass assert config.get_config_value_as_list('nunavut.lang.a', 'bar', [3, 4, 5])[1] == 4 config.set('nunavut.lang.a', 'bar', 1) try: config.get_config_value_as_list('nunavut.lang.a', 'bar') assert False except TypeError: pass assert config.get_config_value_as_list('nunavut.lang.a', 'bar', [4, 5, 6])[1] == 5 :param str section_name : The name of the section to get the key from. :param str key : The config value to retrieve. :param default_value : The value to return if the key was not in the configuration. If provided this method\ will not raise a KeyError nor a TypeError. :type default_value : typing.Optional[typing.List[typing.Any]] :return : Either the value from the config or the default_value if provided. :rtype : typing.List[typing.Any] :raises : KeyError if the key does not exist and a default_value was not provided. :raises : TypeError if the value exists but is not a dict and a default_value was not provided. """ raw_value = self._get_config_value_raw( section_name, key, default_value=(self._UNSET if default_value is None else default_value) ) if isinstance(raw_value, list): return raw_value if default_value is None: raise TypeError("{}.{} exists but is not a list. (is type {})".format(section_name, key, type(raw_value))) return default_value
def apply_defaults(self, language_standard: str) -> None: defaults_key = f"{language_standard}_options" if defaults_key in self.sections()[NUNAVUT_LANG_CPP]: defaults_data = self.get_config_value_as_dict(NUNAVUT_LANG_CPP, defaults_key) self.update_section(NUNAVUT_LANG_CPP, {"options": defaults_data}) def validate_language_options(self) -> None: options = self.get_config_value_as_dict(NUNAVUT_LANG_CPP, "options") ctor_convention_str: str = options["ctor_convention"] ctor_convention = ConstructorConvention.parse_string(ctor_convention_str) if not ctor_convention: raise RuntimeError( f"ctor_convention property '{ctor_convention_str}' is invalid and must be one of " + (",".join([f"'{e.value}'" for e in ConstructorConvention])) ) if ctor_convention != ConstructorConvention.Default and not options["allocator_type"]: raise RuntimeError( f"allocator_type property must be specified when ctor_convention is '{ctor_convention_str}'" )
# +-------------------------------------------------------------------------------------------------------------------+ # | VersionReader # +-------------------------------------------------------------------------------------------------------------------+ class VersionReader: """ Helper to read an "x.y.z" semantic version from python modules as a module variable "__version__" """ MODULE_VERSION_ATTRIBUTE_NAME = "__version__" @classmethod def parse_version(cls, version_string: str) -> typing.Optional[typing.Tuple[int, int, int]]: version_array = [int(x) for x in version_string.split(".")] if len(version_array) != 3: return None else: return (version_array[0], version_array[1], version_array[2]) @classmethod def read_version(cls, module: "types.ModuleType") -> typing.Tuple[int, int, int]: version = getattr(module, cls.MODULE_VERSION_ATTRIBUTE_NAME, "0.0.0") # type: str version_tuple = cls.parse_version(version) if version_tuple is None: raise RuntimeError( 'Invalid {} "{}" for module {} (expected "x.y.z")'.format( cls.MODULE_VERSION_ATTRIBUTE_NAME, version, module.__name__ ) ) return version_tuple def __init__(self, module_name: str): self._module_name = module_name self._cached = None # type: typing.Optional[typing.Tuple[int, int, int]] @property def version(self) -> typing.Tuple[int, int, int]: if self._cached is None: self._cached = self._get_version() return self._cached def _get_version(self) -> typing.Tuple[int, int, int]: import importlib try: return self.read_version(importlib.import_module(self._module_name)) except (ImportError, ValueError): return (0, 0, 0)