import ast
from typing import Optional, Union, Iterable, Any, Sequence
from xml.etree.ElementTree import Element
import numpy as np
from SDF.data_model._helper_functions import pop_element_attribute, element_is_empty, pop_all_child_elements
from SDF.data_model.abstract import XMLWritable
from SDF.data_model.element_set import ElementSet
from SDF.data_model.name import Name
[docs]class ParameterType(XMLWritable):
[docs] @staticmethod
def from_xml_element(element: Element) -> "ParameterType":
if element.tag != "par":
raise ValueError(f"Expected a <par> tag, got '{element.tag}'")
if "value" in element.attrib:
return Parameter.from_xml_element(element)
return ParameterSet.from_xml_element(element)
[docs]class Parameter(ParameterType):
"""Represents a single-valued SDF <par> element"""
def __init__(self, name: Union[str, Name], value: Any, unit: Optional[str] = None):
if isinstance(name, Name):
self.__name = name
else:
self.__name = Name(name)
self.value = _parameter_value_to_string(value).strip()
if unit is None:
self.unit = None
elif isinstance(unit, str):
self.unit = unit.strip()
else:
raise ValueError("Unit must be a string or None")
@property
def name(self) -> str:
return self.__name.name
@property
def parsed_value(self):
"""Try to parse `self.value` as a Python literal (e.g. int, float, list, tuple, str, bytes)"""
try:
# direct literal, e.g. "[1, 2, 3]" -> [1, 2, 3]
return ast.literal_eval(self.value)
except (SyntaxError, ValueError):
pass
if r"\x" in self.value:
try:
# literal containing raw bytes
return ast.literal_eval(f"b'{self.value}'")
except (SyntaxError, ValueError):
pass
# return original string value
return self.value
def __repr__(self):
return f"{self.__class__.__name__}({self.name!r}, {self.value!r}, {self.unit!r})"
[docs] def to_xml_element(self) -> Element:
attributes = dict(name=self.name, value=self.value)
if self.unit is not None:
attributes["unit"] = self.unit
return Element("par", attributes)
[docs] @classmethod
def from_xml_element(cls, element: Element) -> "Parameter":
if element.tag != "par":
raise ValueError(f"Expected a <par> element, got {element.tag}")
name = pop_element_attribute(element, "name")
value = pop_element_attribute(element, "value")
unit = pop_element_attribute(element, "unit") if "unit" in element.attrib else None
ret = cls(name, value, unit)
if not element_is_empty(element):
raise ValueError("Element is not empty")
return ret
def __eq__(self, other):
if isinstance(other, Parameter):
return self.name == other.name and self.value == other.value and self.unit == other.unit
return False
[docs]class AnonymousParameterSet(ElementSet[ParameterType]):
def __init__(self, items: Optional[Iterable[ParameterType]] = None):
super().__init__(key_func=lambda item: item.name,
check_func=lambda item: isinstance(item, (Parameter, ParameterSet)),
items=items)
[docs] def add_from_structure(self, structure: Sequence, parse_names_from_dicts: bool = False):
self.update(*_parse_parameters(structure, parse_names_from_dicts=parse_names_from_dicts))
def __setitem__(self, key, value):
# first try to parse as single parameter
try:
self.add(_parse_to_single_parameter((key, value)))
except (ValueError, TypeError) as ex1:
# on failure: try to parse as parameter set
try:
self.add(ParameterSet(name=key, items=_parse_parameters(value)))
except (ValueError, TypeError):
# if that fails as well: raise first exception since its message is less specific and more helpful
raise ex1
[docs]class ParameterSet(AnonymousParameterSet, ParameterType):
def __init__(self, name: Union[str, Name], items: Optional[Iterable[ParameterType]] = None):
if isinstance(name, Name):
self.__name = name
else:
self.__name = Name(name)
super().__init__(items=items)
@property
def name(self) -> str:
return self.__name.name
[docs] @classmethod
def from_xml_element(cls, element: Element) -> "ParameterSet":
name = pop_element_attribute(element, "name")
ret = cls(name, map(ParameterType.from_xml_element, pop_all_child_elements(element)))
if not element_is_empty(element):
raise ValueError("Element is not empty")
return ret
[docs] def to_xml_element(self) -> Element:
element = Element("par", dict(name=self.name))
for par in self:
element.append(par.to_xml_element())
return element
[docs] def copy(self) -> "ParameterSet":
cpy = self.__class__(name=self.name)
cpy.update(self)
return cpy
def __repr__(self):
return f"{self.__class__.__name__}({self.name!r}, {[item for item in self]!r})"
def __eq__(self, other):
if isinstance(other, ParameterSet):
return self.name == other.name and super().__eq__(other)
return False
def _parse_to_single_parameter(structure: Sequence) -> Parameter:
"""
Check if a sequence can be converted to a single parameter of the structure (name, value) or (name, value, unit)
"""
if isinstance(structure, str):
# prevent "ab" -> Parameter(name="a", value="b")
raise TypeError("Cannot convert a string to Parameter")
if isinstance(structure, dict):
# could use something like `{"name": "abc", "value": 123}`, but that's too fragile
raise TypeError("Cannot convert a dict to a single Parameter")
if len(structure) == 2:
(name, value), unit = structure, None
elif len(structure) == 3:
name, value, unit = structure
else:
raise ValueError(f"Expected a structure of length 2 or 3, got length {len(structure)}")
if isinstance(value, dict):
raise TypeError(f"Value cannot be a dict")
if isinstance(value, list):
raise TypeError("Value cannot be a list")
if isinstance(value, tuple) and len(value) == 2 and isinstance(value[1], str):
value, unit = value
return _parse_to_single_parameter((name, value, unit))
return Parameter(name, value, unit)
def _parse_parameters(structure: Sequence, parse_names_from_dicts: bool = False) -> Iterable[ParameterType]:
"""
Parse a structure (like lists, tuples, dicts and combinations of those) into a collection of parameters
while retaining the original nested structure.
:param structure:
:param parse_names_from_dicts: if true, dict entries with a key 'name' will be parsed into a ParameterSet with that
name, containing the other items of the dict
"""
if isinstance(structure, str):
# prevent "ab" -> Parameter(name="a", value="b")
raise ValueError("Cannot parse a string to parameters")
parameters = AnonymousParameterSet()
# dict with items (name, value[, unit]) or (name, [substructure...])
# dicts have to be handled first, since their __len__ and __iter__ functions do not expose the keys
if isinstance(structure, dict):
if parse_names_from_dicts:
if "name" in structure.keys() and isinstance(structure["name"], (bytes, str)):
name = structure.pop("name")
if isinstance(name, bytes):
name = str(name, "ascii")
parameters.add(ParameterSet(name, items=_parse_parameters(structure)))
return parameters
for item in sorted(structure.items()):
try:
parameters.add(_parse_to_single_parameter(item))
except (ValueError, TypeError):
name, items = item
parameters.add(ParameterSet(name, items=_parse_parameters(items)))
return parameters
# (name, value), (name, value, unit), (name, (value, unit))
try:
parameters.add(_parse_to_single_parameter(structure))
return parameters
except (ValueError, TypeError):
pass
# (name, [substructure...])
if len(structure) == 2 and isinstance(structure[0], str):
name, items = structure
parameters.add(ParameterSet(name, items=_parse_parameters(items)))
return parameters
# Sequence of substructures
for entry in structure:
parameters.update(_parse_parameters(entry))
return parameters
def _parameter_value_to_string(value: Any) -> str:
"""
Convert value to a string representation (hopefully) suitable for usage as xml attribute.
Obviously, we cannot handle all possible input types.
"""
# bytes to string
if isinstance(value, bytes):
value = repr(value) # "b'value'"
return value[2:-1] # strip leading b' and trailing '
# ensure strings to be printable
elif isinstance(value, str):
value = repr(value) # "'value'"
value = value[1:-1] # strip leading and trailing '
value = value.replace("\\\\", "\\") # str.__repr__ duplicates backslashes
return value
# format numpy array output
elif isinstance(value, np.ndarray):
# returns a (possibly nested) list
# lists have a one-line and easily parsable string representation, while np.ndarray has not
# can be parsed with `np.array(ast.literal_eval(...))`
value = repr(value.tolist())
return value
else:
return str(value).strip()