# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
# Contributors:
# INITIAL AUTHORS - initial API and implementation and/or initial
# documentation
# :author: Francois Gallard
# OTHER AUTHORS - MACROSCOPIC CHANGES
"""Base class for validating data structures."""
from __future__ import annotations
import logging
from abc import abstractmethod
from copy import copy
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar
from typing import Optional
from gemseo.core.data_converters.factory import DataConverterFactory
from gemseo.core.grammars.defaults import Defaults
from gemseo.core.grammars.errors import InvalidDataError
from gemseo.core.grammars.required_names import RequiredNames
from gemseo.core.namespaces import MutableNamespacesMapping
from gemseo.core.namespaces import namespaces_separator
from gemseo.core.namespaces import remove_prefix
from gemseo.core.namespaces import update_namespaces
from gemseo.typing import StrKeyMapping
from gemseo.utils.metaclasses import ABCGoogleDocstringInheritanceMeta
from gemseo.utils.string_tools import MultiLineString
from gemseo.utils.string_tools import pretty_str
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import KeysView
from collections.abc import Mapping
from typing_extensions import Self
from gemseo.core.data_converters.base import BaseDataConverter
from gemseo.core.grammars.simple_grammar import SimpleGrammar
SimpleGrammarTypes = Mapping[str, Optional[type[Any]]]
LOGGER = logging.getLogger(__name__)
[docs]
class BaseGrammar(
StrKeyMapping,
metaclass=ABCGoogleDocstringInheritanceMeta,
):
"""An abstract base class for grammars with a dictionary-like interface.
A grammar considers a certain type of data defined by mandatory and optional names
bound to types. A name-type pair is referred to as a grammar *element*. A grammar
can validate a data from these elements.
"""
name: str
"""The name of the grammar."""
to_namespaced: MutableNamespacesMapping
"""The mapping from element names without namespace prefix to element names with
namespace prefix."""
from_namespaced: MutableNamespacesMapping
"""The mapping from element names with namespace prefix to element names without
namespace prefix."""
_defaults: Defaults
"""The mapping from the names to the default values, if any."""
_data_converter: BaseDataConverter[BaseGrammar]
"""The converter of data values to NumPy arrays and vice-versa."""
_required_names: RequiredNames
"""The names of the required elements."""
DATA_CONVERTER_CLASS: ClassVar[str | type[BaseDataConverter[BaseGrammar]]]
"""The class or the class name of the data converter."""
def __init__(
self,
name: str,
) -> None:
"""
Args:
name: The name of the grammar.
Raises:
ValueError: If the name is empty.
""" # noqa: D205, D212, D415
if not name:
msg = "The grammar name cannot be empty."
raise ValueError(msg)
self.name = name
self.clear()
self.__create_data_converter(self.DATA_CONVERTER_CLASS)
def __str__(self) -> str:
return f"Grammar name: {self.name}"
def __string_representation(self) -> MultiLineString:
"""Return the string representation of the grammar.
Returns:
The string representation of the grammar.
"""
text = MultiLineString()
text.add(str(self))
text.indent()
text.add("Required elements:")
text.indent()
self.__update_grammar_repr(text, True)
text.dedent()
text.add("Optional elements:")
text.indent()
self.__update_grammar_repr(text, False)
return text
def __repr__(self) -> str:
return str(self.__string_representation())
def _repr_html_(self) -> str:
return self.__string_representation()._repr_html_()
def __delitem__(
self,
name: str,
) -> None:
self._check_name(name)
self._defaults.pop(name, None)
self._required_names.discard(name)
self._delitem(name)
@abstractmethod
def _delitem(self, name: str) -> None:
"""Remove an element but the defaults.
Args:
name: The name of the element to remove.
"""
def __copy__(self) -> Self:
"""Create a shallow copy.
Returns:
The shallow copy.
"""
grammar = self.__class__(self.name)
grammar.to_namespaced = copy(self.to_namespaced)
grammar.from_namespaced = copy(self.from_namespaced)
grammar._required_names = copy(self._required_names)
self._copy(grammar)
grammar._defaults.update(self._defaults)
return grammar
copy = __copy__
@abstractmethod
def _copy(self, grammar: Self) -> None:
"""Copy the specific attribute of a derived class.
Args:
grammar: The grammar to be copied into.
"""
def __update_grammar_repr(self, repr_: MultiLineString, required: bool) -> None:
"""Update the string representation of the grammar with that of its elements.
Args:
repr_: The string representation of the grammar.
required: Whether to show the required elements or the other ones.
"""
for name, properties in self.items():
if (name in self._required_names) == required:
repr_.add(f"{name}:")
repr_.indent()
self._update_grammar_repr(repr_, properties)
if not required:
repr_.add(f"Default: {self._defaults.get(name, 'N/A')}")
repr_.dedent()
@abstractmethod
def _update_grammar_repr(self, repr_: MultiLineString, properties: Any) -> None:
"""Update the string representation of the grammar with an element.
Args:
repr_: The string representation of the grammar.
properties: The properties of the element.
"""
@property
def names(self) -> KeysView[str]:
"""The names of the elements."""
return self.keys()
@property
def names_without_namespace(self) -> Iterator[str]:
"""The names of the elements without namespace prefixes."""
return remove_prefix(self.keys())
[docs]
def has_names(self, names: Iterable[str]) -> bool:
"""Return whether names are all element names.
Args:
names: The names to check.
Returns:
Whether the names are all element names.
"""
return set(self.keys()).issuperset(names)
@property
def defaults(self) -> Defaults:
"""The mapping from the names to the default values, if any."""
return self._defaults
@defaults.setter
def defaults(self, data: StrKeyMapping) -> None:
self._defaults = Defaults(self, data)
@property
def required_names(self) -> RequiredNames:
"""The names of the required elements."""
return self._required_names
[docs]
def clear(self) -> None:
"""Empty the grammar."""
# _clear shall be called first because it creates specific attributes
# of derived classes that may be used by the next statements.
self._clear()
self.to_namespaced = {}
self.from_namespaced = {}
self._defaults = Defaults(self, {})
self._required_names = RequiredNames(self)
@abstractmethod
def _clear(self) -> None:
"""Empty specifically the grammar but the common attributes."""
[docs]
def update(
self,
grammar: Self,
excluded_names: Iterable[str] = (),
merge: bool = False,
) -> None:
"""Update the grammar from another grammar.
If ``grammar`` has namespaces, they will be added to the current grammar.
Args:
grammar: The grammar to update from.
excluded_names: The names of the elements that shall not be updated.
merge: Whether to merge or update the grammar.
"""
if not grammar:
return
self._update(grammar, excluded_names, merge)
self.__update_namespaces_from_grammar(grammar)
self._defaults.update({
k: v for k, v in grammar._defaults.items() if k not in excluded_names
})
self._required_names |= (grammar.keys() - excluded_names).intersection(
grammar._required_names.get_names_difference(excluded_names)
)
@abstractmethod
def _update(
self,
grammar: Self,
excluded_names: Iterable[str],
merge: bool,
) -> None:
"""Update specifically the grammar from another grammar.
Args:
grammar: The grammar to update from.
excluded_names: The names of the elements that shall not be updated.
merge: Whether to merge or update the grammar.
"""
[docs]
def update_from_types(
self,
names_to_types: SimpleGrammarTypes,
merge: bool = False,
) -> None:
"""Update the grammar from names bound to types.
The updated elements are required.
Args:
names_to_types: The mapping defining the data names as keys,
and data types as values.
merge: Whether to merge or update the grammar.
"""
if not names_to_types:
return
self._update_from_types(names_to_types, merge)
self._required_names |= names_to_types.keys()
@abstractmethod
def _update_from_types(
self,
names_to_types: SimpleGrammarTypes,
merge: bool,
) -> None:
"""Update specifically the grammar from names bound to types.
Args:
names_to_types: The mapping defining the data names as keys,
and data types as values.
merge: Whether to merge or update the grammar.
"""
[docs]
def update_from_data(
self,
data: StrKeyMapping,
merge: bool = False,
) -> None:
"""Update the grammar from name-value pairs.
The updated elements are required.
Args:
data: The data from which to get the names and types,
typically ``{element_name: element_value}``.
merge: Whether to merge or update the grammar.
"""
if not data:
return
self._update_from_data(data, merge)
self._required_names |= data.keys()
def _update_from_data(
self,
data: StrKeyMapping,
merge: bool,
) -> None:
"""Update specifically the grammar from name-value pairs.
The updated elements are required.
Args:
data: The data from which to get the names and types,
typically ``{element_name: element_value}``.
merge: Whether to merge or update the grammar.
"""
self._update_from_types(
{name: type(value) for name, value in data.items()}, merge=merge
)
[docs]
def update_from_names(
self,
names: Iterable[str],
merge: bool = False,
) -> None:
"""Update the grammar from names.
The updated elements are required and bind the names to NumPy arrays.
Args:
names: The names to update from.
merge: Whether to merge or update the grammar.
"""
if not names:
return
self._update_from_names(names, merge)
self._required_names |= set(names)
@abstractmethod
def _update_from_names(
self,
names: Iterable[str],
merge: bool,
) -> None:
"""Update specifically the grammar from names.
Args:
names: The names to update from.
merge: Whether to merge or update the grammar.
"""
[docs]
def validate(
self,
data: StrKeyMapping,
raise_exception: bool = True,
) -> None:
"""Validate data against the grammar.
Args:
data: The data to be checked,
with a dictionary-like format: ``{element_name: element_value}``.
raise_exception: Whether to raise an exception when the validation fails.
Raises:
InvalidDataError: If the validation fails and ``raise_exception`` is
``True``.
"""
error_message = MultiLineString()
error_message.add(f"Grammar {self.name}: validation failed.")
missing_names = self._required_names.get_names_difference(data)
if missing_names:
error_message.add(f"Missing required names: {pretty_str(missing_names)}.")
data_is_valid = False
else:
data_is_valid = self._validate(data, error_message)
if not data_is_valid:
LOGGER.error(error_message)
if raise_exception:
raise InvalidDataError(str(error_message)) from None
@abstractmethod
def _validate(
self,
data: StrKeyMapping,
error_message: MultiLineString,
) -> bool:
"""Validate data but for the required names.
Args:
data: The data to be checked.
error_message: The error message.
Returns:
Whether the validation passed.
"""
@property
def data_converter(self) -> BaseDataConverter[BaseGrammar]:
"""The converter of data values to NumPy arrays and vice versa."""
return self._data_converter
[docs]
def to_simple_grammar(self) -> SimpleGrammar:
"""Convert the grammar to a :class:`.SimpleGrammar`.
Returns:
A :class:`.SimpleGrammar` version of the current grammar.
"""
from gemseo.core.grammars.simple_grammar import SimpleGrammar
grammar = SimpleGrammar(
self.name,
names_to_types=self._get_names_to_types(),
required_names=self._required_names,
)
grammar.defaults = self._defaults
return grammar
@abstractmethod
def _get_names_to_types(self) -> SimpleGrammarTypes:
"""Create the mapping from element names to elements types.
The elements for which types definitions cannot be expressed as a unique Python
type, the type is set to ``None``.
Returns:
The mapping from element names to elements types.
"""
[docs]
def restrict_to(
self,
names: Iterable[str],
) -> None:
"""Restrict the grammar to the given names.
Args:
names: The names of the elements to restrict the grammar to.
Raises:
KeyError: If a name is not in the grammar.
"""
self._check_name(*names)
for name in self._defaults.keys() - names:
del self._defaults[name]
self._required_names &= set(names)
self._restrict_to(names)
@abstractmethod
def _restrict_to(
self,
names: Iterable[str],
) -> None:
"""Restrict the grammar to the given names but for the defaults.
Args:
names: The names of the elements to restrict the grammar to.
"""
[docs]
def rename_element(self, current_name: str, new_name: str) -> None:
"""Rename an element.
Args:
current_name: The current name of the element.
new_name: The new name of the element.
"""
self._check_name(current_name)
self._rename_element(current_name, new_name)
if current_name in self._required_names:
self._required_names.remove(current_name)
self._required_names.add(new_name)
default_value = self._defaults.pop(current_name, None)
if default_value is not None:
self._defaults[new_name] = default_value
@abstractmethod
def _rename_element(self, current_name: str, new_name: str) -> None:
"""Rename an element without checking its name and ignoring the defaults.
Args:
current_name: The current name of the element.
new_name: The new name of the element.
"""
@abstractmethod
def _check_name(self, *names: str) -> None:
"""Check that the names of elements are valid.
Args:
*names: The names to be checked.
Raises:
KeyError: If a name is not valid.
"""
def __update_namespaces_from_grammar(self, grammar: Self) -> None:
"""Update the namespaces according to another grammar namespaces.
Args:
grammar: The grammar to update from.
"""
if grammar.to_namespaced:
update_namespaces(self.to_namespaced, grammar.to_namespaced)
if grammar.from_namespaced:
update_namespaces(self.from_namespaced, grammar.from_namespaced)
[docs]
def add_namespace(self, name: str, namespace: str) -> None:
"""Add a namespace prefix to an existing grammar element.
The updated element name will be
``namespace``+:data:`~gemseo.core.namespaces.namespace_separator`+``name``.
Args:
name: The element name to rename.
namespace: The name of the namespace.
Raises:
ValueError: If the variable already has a namespace.
"""
self._check_name(name)
if namespaces_separator in name:
msg = f"The variable {name} already has a namespace."
raise ValueError(msg)
new_name = namespace + namespaces_separator + name
self.rename_element(name, new_name)
self.to_namespaced[name] = new_name
self.from_namespaced[new_name] = name
def __create_data_converter(
self,
cls: type[BaseDataConverter[BaseGrammar]] | str,
) -> None:
"""Create the data converter.
Args:
cls: The class or the class name of the data
"""
if isinstance(cls, str):
cls = DataConverterFactory().get_class(cls)
self._data_converter = cls(grammar=self)