Source code for nunavut.lang._config

#
# Copyright (C) OpenCyphal Development Team  <opencyphal.org>
# Copyright Amazon.com Inc. or its affiliates.
# SPDX-License-Identifier: MIT
#
"""
Logic for parsing language configuration.
"""
import re
import types
import typing

from yaml import SafeLoader as YamlLoader
from yaml import load as yaml_loader

from nunavut._utilities import deep_update, no_default_value

# +-------------------------------------------------------------------------------------------------------------------+
# | LANGUAGE CONFIGURATION
# +-------------------------------------------------------------------------------------------------------------------+


[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_yaml_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_yaml_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 identifiers. def __init__(self): # type: ignore self._sections: 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( f'Section name "{section_name}" is invalid. See LanguageConfig documentation for rules.' ) self.update_section(section_name, section_data)
[docs] def update_section(self, section_name: str, configuration: typing.Any) -> None: """ Update a section of the configuration. """ self._sections[section_name] = deep_update(self._sections.get(section_name, {}), configuration)
[docs] def sections(self) -> typing.Dict[str, typing.Dict[str, typing.Any]]: """ Get all sections of the configuration. """ return self._sections
[docs] def update_from_yaml_string(self, string: str) -> None: """ Update the configuration from a yaml string. Calls :meth:`update` with the parsed yaml data and will raise the same exceptions. """ configuration = yaml_loader(string, Loader=YamlLoader) self.update(configuration)
[docs] def update_from_yaml_file(self, f: typing.TextIO) -> None: """ Update the configuration from a yaml file. Calls :meth:`update` with the parsed yaml data and will raise the same exceptions. """ configuration = yaml_loader(f, Loader=YamlLoader) self.update(configuration)
[docs] def set(self, section: str, option: str, value: typing.Any) -> None: """ Set a configuration value. :param section: The section to set the value in. :param option: The option to set. :param value: The value to set. """ 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(f'Section name "{section_name}" is invalid. See LanguageConfig documentation for rules.') if section_name in self._sections: raise ValueError(f"Section {section_name} is already defined.") self._sections[section_name] = {}
_UNSET = object() # Used internally to allow "None" as a default value. @no_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 raise try: return section_data[key] except KeyError: if default_value is not self._UNSET: return default_value 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 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(f"{section_name}.{key} exists but is not a dict. (is type {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(f"{section_name}.{key} exists but is not a list. (is type {type(raw_value)})") return default_value
# +-------------------------------------------------------------------------------------------------------------------+ # | VersionReader # +-------------------------------------------------------------------------------------------------------------------+ class VersionReader: """ Helper to read an "x.y.z" semantic version from python modules as a module variable `MODULE_VERSION_ATTRIBUTE_NAME`. :param module_name: The name of the module to read the version from. """ MODULE_VERSION_ATTRIBUTE_NAME = "__version__" @classmethod def parse_version(cls, version_string: str) -> typing.Optional[typing.Tuple[int, int, int]]: """ Parse a version string into a tuple of (major, minor, patch). :param version_string: The version string to parse. :return: The version as a tuple of (major, minor, patch) or None if the version string is not in the expected format. """ version_array = [int(x) for x in version_string.split(".")] if len(version_array) != 3: return None return (version_array[0], version_array[1], version_array[2]) @classmethod def read_version(cls, module: "types.ModuleType") -> typing.Tuple[int, int, int]: """ Read the version from a module. :param module: The module to read the version from. :return: The version as a tuple of (major, minor, patch). :raises: ValueError if the version is not in the expected format. """ version: str = getattr(module, cls.MODULE_VERSION_ATTRIBUTE_NAME, "0.0.0") version_tuple = cls.parse_version(version) if version_tuple is None: raise ValueError( f'Invalid {cls.MODULE_VERSION_ATTRIBUTE_NAME} "{version}" for module {module.__name__}' '(expected "x.y.z")' ) return version_tuple def __init__(self, module_name: str): self._module_name = module_name self._cached: typing.Optional[typing.Tuple[int, int, int]] = None @property def version(self) -> typing.Tuple[int, int, int]: """ The version of the module as a tuple of (major, minor, patch). """ if self._cached is None: self._cached = self._get_version() return self._cached def _get_version(self) -> typing.Tuple[int, int, int]: import importlib # pylint: disable=import-outside-toplevel try: return self.read_version(importlib.import_module(self._module_name)) except (ImportError, ValueError): return (0, 0, 0)